mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
* Add an OkLab gradient interpolation space and make it the new default * Fix the Properties panel fill row resetting the gradient interpolation space to the default * Add OkLch, Lab, LCh, and HSL gradient interpolation spaces * Add a hue direction attribute and picker choice for polar gradient interpolation spaces * Add an HSV gradient interpolation space * Rename the sRGB Linear and sRGB Gamma gradient spaces to RGB Linear and RGB Gamma * Divide the gradient interpolation dropdown between absolute and relative color spaces * Rename the OkLch gradient interpolation space to OkLCh for channel-notation capitalization * Shorten the color picker popover's hue direction row label to Arc * Call the Gradient Interpolation node's parameter Space and extend the picker tooltip title to match * Rename the gradient interpolation attribute and type family to gradient space * Rename the gradient tool's gradient space transform to gradient to viewport transform * Add serde aliases and a node replacement covering the gradient space renames * Give the gradient space choices artist-facing labels and reorder the dropdown sections * Add a Gradient Hue Direction node and its attribute read node * Say interpolate instead of blend for gradient stop color traversal in docs and tooltips * Label the polar perceptual gradient spaces as Perceptual Hue and group the dropdown sections by geometry * Add a migration alias covering the gradient space attribute reader rename * Carry the gradient hue direction attribute through boolean operations * Register GradientHueDirection wire types in the node registry
4405 lines
170 KiB
Rust
4405 lines
170 KiB
Rust
use core::cmp::Ordering;
|
||
use core::f64::consts::{PI, TAU};
|
||
use core::hash::{Hash, Hasher};
|
||
use core_types::attribute::Transform as TransformAttr;
|
||
use core_types::attribute::{Attr, BlendMode as BlendModeAttr, ClippingMask, EditorLayerPath, Opacity, OpacityFill};
|
||
use core_types::blending::BlendMode;
|
||
use core_types::bounds::{BoundingBox, RenderBoundingBox};
|
||
use core_types::context::IndexLink;
|
||
use core_types::extent::{ExtentIn, LevelIn, ListIn, ValueIn};
|
||
use core_types::gpoll::GraphError;
|
||
use core_types::gpoll::Interrupt;
|
||
use core_types::gpoll::{Extent, GPoll};
|
||
use core_types::list::{Item, ItemAttributeValues, List};
|
||
use core_types::node::Lane;
|
||
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
|
||
use core_types::transform::Transform;
|
||
use core_types::uuid::NodeId;
|
||
use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex};
|
||
use glam::{DAffine2, DMat2, DVec2};
|
||
use graphic_types::graphic::{bake_paint_transforms, has_paint, is_paint_present, set_paint_attribute_at};
|
||
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
|
||
use graphic_types::raster_types::{CPU, GPU, Raster};
|
||
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList};
|
||
use graphic_types::{Artboard, Vector};
|
||
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::ATTR_GRADIENT_FORM;
|
||
use vector_types::GradientForm;
|
||
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};
|
||
|
||
/// The gradient color for one assign-colors position, replaying the
|
||
/// randomized draws up to it.
|
||
fn assign_color_at(
|
||
gradient: &Gradient,
|
||
gradient_space: vector_types::GradientSpace,
|
||
gradient_hue_direction: vector_types::GradientHueDirection,
|
||
position: usize,
|
||
length: usize,
|
||
randomize: bool,
|
||
seed: SeedValue,
|
||
repeat_every: u32,
|
||
) -> Color {
|
||
let factor = match randomize {
|
||
true => {
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
let mut draw = 0.;
|
||
for _ in 0..=position {
|
||
draw = rng.random::<f64>();
|
||
}
|
||
draw
|
||
}
|
||
false => match repeat_every {
|
||
0 => position as f64 / (length - 1).max(1) as f64,
|
||
1 => 0.,
|
||
_ => position as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
|
||
},
|
||
};
|
||
gradient.evaluate(factor, Default::default(), gradient_space, gradient_hue_direction)
|
||
}
|
||
|
||
/// 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), extent(assign_colors_extent))]
|
||
fn assign_colors<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
/// The content with vector paths to apply the fill and/or stroke style to.
|
||
#[widget(ParsedWidgetOverride::Hidden)]
|
||
content: IList<Vector>,
|
||
/// Whether to style the fill.
|
||
#[default(true)]
|
||
fill: bool,
|
||
/// Whether to style the stroke.
|
||
stroke: bool,
|
||
/// The range of colors to select from.
|
||
#[default(Color::BLACK, Color::WHITE)]
|
||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_gradient")]
|
||
gradient: IList<Gradient>,
|
||
/// Whether to reverse the gradient.
|
||
reverse: bool,
|
||
/// Whether to randomize the color selection for each element from throughout the gradient.
|
||
randomize: bool,
|
||
/// The seed used for randomization.
|
||
/// Seed to determine unique variations on the randomized color selection.
|
||
#[widget(ParsedWidgetOverride::Custom = "assign_colors_seed")]
|
||
seed: 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: u32,
|
||
) -> Result<IList<(Lane<Vector>, Attr<'e, Fill>, Attr<'e, StrokeAttr>)>, Interrupt> {
|
||
let lane = ctx.index() as usize;
|
||
if lane >= content.len() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
let element = content.element_ref(lane).clone();
|
||
let park_existing = |paint: Option<&List<Graphic<'static>>>| -> Result<Option<&'e List<Graphic>>, Interrupt> { paint.map(|paint| park_paint(ctx.arena(), paint.clone())).transpose() };
|
||
let existing_fill = park_existing(content.lane(lane).attr::<Fill>())?;
|
||
let existing_stroke = park_existing(content.lane(lane).attr::<StrokeAttr>())?;
|
||
|
||
if gradient.is_empty() {
|
||
return Ok((content.lane(lane).map_element(element), Attr(existing_fill), Attr(existing_stroke)));
|
||
}
|
||
let gradient_space = gradient.lane(0).attr::<vector_types::markers::GradientSpace>();
|
||
let gradient_hue_direction = gradient.lane(0).attr::<vector_types::markers::GradientHueDirection>();
|
||
let gradient_element = gradient.element_ref(0);
|
||
let reversed;
|
||
let gradient_element = match reverse {
|
||
true => {
|
||
reversed = gradient_element.reversed();
|
||
&reversed
|
||
}
|
||
false => gradient_element,
|
||
};
|
||
|
||
let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, lane, content.len(), randomize, seed, repeat_every);
|
||
let paint = List::new_from_element(color).into_graphic_list();
|
||
let parked = park_paint(ctx.arena(), paint)?;
|
||
|
||
let fill_attr = match fill {
|
||
true => Some(parked),
|
||
false => existing_fill,
|
||
};
|
||
let stroke_attr = match stroke && element.stroke.is_some() {
|
||
true => Some(parked),
|
||
false => existing_stroke,
|
||
};
|
||
Ok((content.lane(lane).map_element(element), Attr(fill_attr), Attr(stroke_attr)))
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn assign_colors_extent(
|
||
content: ListIn<'_, Vector>,
|
||
_fill: ValueIn<'_, bool>,
|
||
_stroke: ValueIn<'_, bool>,
|
||
_gradient: ListIn<'_, Gradient>,
|
||
_reverse: ValueIn<'_, bool>,
|
||
_randomize: ValueIn<'_, bool>,
|
||
_seed: ValueIn<'_, SeedValue>,
|
||
_repeat_every: ValueIn<'_, u32>,
|
||
level: LevelIn,
|
||
) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total(),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
/// The color assignment over graphic lanes: the running position spans the
|
||
/// interior vectors of every lane, as the pre-flip broadcast did. Registered
|
||
/// under the assign colors identifier.
|
||
#[node_macro::node(category(""), extent(assign_colors_graphic_extent))]
|
||
fn assign_colors_graphic<'e>(
|
||
ctx: impl Ctx + CacheHash + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Graphic<'static>>,
|
||
#[data] lane_offsets: std::sync::Arc<std::sync::Mutex<Option<LaneOffsets>>>,
|
||
#[default(true)] fill: bool,
|
||
stroke: bool,
|
||
gradient: IList<Gradient>,
|
||
reverse: bool,
|
||
randomize: bool,
|
||
seed: SeedValue,
|
||
repeat_every: u32,
|
||
) -> Result<IList<Lane<Graphic<'e>>>, Interrupt> {
|
||
let lane = ctx.index() as usize;
|
||
if lane >= content.len() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
let original = content.element_ref(lane);
|
||
|
||
if gradient.is_empty() {
|
||
return Ok(content.lane(lane).map_element(original.clone()));
|
||
}
|
||
let gradient_space = gradient.lane(0).attr::<vector_types::markers::GradientSpace>();
|
||
let gradient_hue_direction = gradient.lane(0).attr::<vector_types::markers::GradientHueDirection>();
|
||
let gradient_element = gradient.element_ref(0);
|
||
let reversed;
|
||
let gradient_element = match reverse {
|
||
true => {
|
||
reversed = gradient_element.reversed();
|
||
&reversed
|
||
}
|
||
false => gradient_element,
|
||
};
|
||
|
||
// The interiors the pre-flip node reached: only a lane's DIRECT vector
|
||
// list, so wrapped groups keep their own styling and consume no position.
|
||
let key = {
|
||
let mut keyed = *ctx;
|
||
core_types::context::InjectIndex::set_index(&mut keyed, 0);
|
||
core_types::registry::cache_key(&keyed)
|
||
};
|
||
let generation = ctx.arena().generation();
|
||
let (length, position) = {
|
||
let mut cached = lane_offsets.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
if !matches!(cached.as_ref(), Some(entry) if entry.key == key && entry.generation == generation) {
|
||
let mut offsets = Vec::with_capacity(content.len() + 1);
|
||
let mut running = 0;
|
||
offsets.push(running);
|
||
for row in 0..content.len() {
|
||
running += graphic_types::graphic::direct_vector_len(content.element_ref(row));
|
||
offsets.push(running);
|
||
}
|
||
*cached = Some(LaneOffsets { key, generation, offsets });
|
||
}
|
||
let entry = cached.as_ref().expect("populated above");
|
||
(entry.offsets[content.len()], entry.offsets[lane])
|
||
};
|
||
|
||
// The direct vector rows as a scratch list, one color per row, rebuilt as
|
||
// a native run; a lane without direct rows passes through untouched.
|
||
let rows = match original {
|
||
Graphic::Vector(vector) => Some(List::new_from_element(vector.clone())),
|
||
Graphic::Group(group) if group.row.is_none() => graphic_types::graphic::run_to_list::<Vector>(&group.content),
|
||
_ => None,
|
||
};
|
||
let element = match rows {
|
||
Some(mut rows) => {
|
||
for row in 0..rows.len() {
|
||
let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some());
|
||
let color = assign_color_at(gradient_element, gradient_space, gradient_hue_direction, position + row, length, randomize, seed, repeat_every);
|
||
let paint = List::new_from_element(color).into_graphic_list();
|
||
if fill {
|
||
set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone());
|
||
}
|
||
if stroke && has_stroke {
|
||
set_paint_attribute_at(&mut rows, row, ATTR_STROKE, paint.clone());
|
||
}
|
||
}
|
||
let content = core_types::record::GroupItem::from_list(rows, ctx.arena()).ok_or_else(|| Interrupt::from(GraphError::new("the arena is exhausted")))?;
|
||
Graphic::Group(core_types::record::Group { row: None, content })
|
||
}
|
||
None => original.clone(),
|
||
};
|
||
|
||
Ok(content.lane(lane).map_element(element))
|
||
}
|
||
|
||
/// Where each lane's colors start in the level's flattened vector run, valid
|
||
/// for one key and generation. `offsets` holds one entry per lane plus the total.
|
||
#[derive(Debug)]
|
||
pub struct LaneOffsets {
|
||
key: u64,
|
||
generation: u64,
|
||
offsets: Vec<usize>,
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn assign_colors_graphic_extent(
|
||
content: ListIn<'_, Graphic>,
|
||
_fill: ValueIn<'_, bool>,
|
||
_stroke: ValueIn<'_, bool>,
|
||
_gradient: ListIn<'_, Gradient>,
|
||
_reverse: ValueIn<'_, bool>,
|
||
_randomize: ValueIn<'_, bool>,
|
||
_seed: ValueIn<'_, SeedValue>,
|
||
_repeat_every: ValueIn<'_, u32>,
|
||
level: LevelIn,
|
||
) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total(),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
pub use _assign_colors_graphic_mod::assign_colors_graphic_entries;
|
||
|
||
/// Keyed, so a group-free paint's promote moves this header rather than
|
||
/// cloning the content it owns.
|
||
fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List<Graphic<'static>>) -> Result<&'e List<Graphic<'static>>, Interrupt> {
|
||
let (parked, _) = arena.alloc_sized_keyed(paint, 0).ok_or(GraphError {
|
||
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
|
||
trace: Vec::new(),
|
||
})?;
|
||
Ok(parked)
|
||
}
|
||
|
||
/// The gradient defaulting the legacy fill performed, applied to the nested
|
||
/// stops list the paint table wraps.
|
||
fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>, gradient_type: GradientForm, transform: Option<DAffine2>) {
|
||
let has_type = paint.iter_attribute_values::<GradientForm>(ATTR_GRADIENT_FORM).is_some();
|
||
let has_transform = paint.iter_attribute_values::<DAffine2>(ATTR_TRANSFORM).is_some();
|
||
for index in 0..paint.len() {
|
||
if !matches!(paint.element(index), Some(Graphic::Gradient(_))) {
|
||
continue;
|
||
}
|
||
if !has_type {
|
||
paint.set_attribute(ATTR_GRADIENT_FORM, index, gradient_type);
|
||
}
|
||
if !has_transform {
|
||
let transform = transform.unwrap_or_else(|| {
|
||
// 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])
|
||
});
|
||
paint.set_attribute(ATTR_TRANSFORM, index, transform);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The materialized paint level as the canonical owned paint list, content
|
||
/// kept in its native form.
|
||
fn paint_table(paint: core_types::node::List<'_, Graphic<'_>>) -> List<Graphic<'static>> {
|
||
let item = paint.as_group_item();
|
||
graphic_types::graphic::run_to_list::<Graphic>(&item).expect("a paint level holds graphic lanes")
|
||
}
|
||
|
||
/// 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"))]
|
||
fn fill<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
/// The content with vector paths to apply the fill style to.
|
||
(element, _content_fill): (Vector, Attr<Fill>),
|
||
/// The fill to paint the path with.
|
||
#[default(Color::BLACK)]
|
||
fill: IList<Graphic<'static>>,
|
||
_backup_color: IList<Color>,
|
||
_backup_gradient: IList<Gradient>,
|
||
_gradient_form: GradientForm,
|
||
_has_transform: bool,
|
||
_transform: DAffine2,
|
||
) -> Result<(Vector, Attr<'e, Fill>), Interrupt> {
|
||
let mut paint = paint_table(fill);
|
||
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_form, _has_transform.then_some(_transform));
|
||
let parked = park_paint(ctx.arena(), paint)?;
|
||
Ok((element, Attr(Some(parked))))
|
||
}
|
||
|
||
/// The fill over graphic lanes: the marker parks on the lane and the render
|
||
/// boundary moves it onto the interior vector lists the legacy paint readers
|
||
/// inspect. Registered under the fill's identifier.
|
||
#[node_macro::node(category(""))]
|
||
fn fill_graphic_leveled<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
(element, _content_fill): (Graphic<'static>, Attr<Fill>),
|
||
#[default(Color::BLACK)] fill: IList<Graphic<'static>>,
|
||
_backup_color: IList<Color>,
|
||
_backup_gradient: IList<Gradient>,
|
||
_gradient_form: GradientForm,
|
||
_has_transform: bool,
|
||
_transform: DAffine2,
|
||
) -> Result<(Graphic<'static>, Attr<'e, Fill>), Interrupt> {
|
||
let bounds = match BoundingBox::bounding_box(&element, DAffine2::IDENTITY, false) {
|
||
RenderBoundingBox::Rectangle(bounds) => Some(bounds),
|
||
_ => None,
|
||
};
|
||
let mut paint = paint_table(fill);
|
||
default_gradient_paint(&mut paint, bounds, _gradient_form, _has_transform.then_some(_transform));
|
||
let parked = park_paint(ctx.arena(), paint)?;
|
||
Ok((element, Attr(Some(parked))))
|
||
}
|
||
|
||
/// 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"))]
|
||
fn stroke<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
/// The content with vector paths to apply the stroke style to.
|
||
(element, content_transform): (Vector, Attr<TransformAttr>),
|
||
/// The stroke paint.
|
||
#[default(Color::BLACK)]
|
||
paint: IList<Graphic<'static>>,
|
||
/// The stroke thickness.
|
||
#[unit(" px")]
|
||
#[default(2.)]
|
||
weight: f64,
|
||
/// The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.
|
||
align: StrokeAlign,
|
||
/// The shape of the stroke at open endpoints.
|
||
cap: StrokeCap,
|
||
/// The curvature of the bent stroke at sharp corners.
|
||
join: 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: f64,
|
||
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
|
||
paint_order: 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: DashPattern,
|
||
/// The phase offset distance from the starting point of the dash pattern.
|
||
#[unit(" px")]
|
||
dash_offset: f64,
|
||
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
|
||
let dash_lengths = dash_pattern.clamped_lengths();
|
||
let mut stroke = Stroke {
|
||
weight,
|
||
dash_lengths,
|
||
dash_offset,
|
||
cap,
|
||
join,
|
||
join_miter_limit: miter_limit,
|
||
align,
|
||
transform: DAffine2::IDENTITY,
|
||
paint_order,
|
||
};
|
||
stroke.transform *= *content_transform;
|
||
|
||
let mut element = element;
|
||
element.stroke = Some(stroke);
|
||
|
||
let paint = paint_table(paint);
|
||
let parked = park_paint(ctx.arena(), paint)?;
|
||
Ok((element, Attr(*content_transform), Attr(Some(parked))))
|
||
}
|
||
|
||
/// The vector items of a graphic lane's interior, one wrap level deep, the
|
||
/// reach of the pre-flip broadcast over a legacy list.
|
||
fn for_each_interior_vector_mut(element: &mut Graphic, mut f: impl FnMut(&mut Vector, DAffine2)) {
|
||
match element {
|
||
Graphic::Vector(vector) => f(vector, DAffine2::IDENTITY),
|
||
Graphic::Graphic(children) => {
|
||
for index in 0..children.len() {
|
||
let transform: DAffine2 = children.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
if let Some(Graphic::Vector(vector)) = children.element_mut(index) {
|
||
f(vector, transform);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// The stroke over graphic lanes: the style applies to the interior vectors,
|
||
/// the paint marker parks on the lane for the render boundary to place.
|
||
/// Registered under the stroke's identifier.
|
||
#[node_macro::node(category(""))]
|
||
fn stroke_graphic_leveled<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
(element, content_transform): (Graphic<'static>, Attr<TransformAttr>),
|
||
#[default(Color::BLACK)] paint: IList<Graphic<'static>>,
|
||
#[unit(" px")]
|
||
#[default(2.)]
|
||
weight: f64,
|
||
align: StrokeAlign,
|
||
cap: StrokeCap,
|
||
join: StrokeJoin,
|
||
#[default(4.)] miter_limit: f64,
|
||
paint_order: PaintOrder,
|
||
dash_pattern: DashPattern,
|
||
#[unit(" px")] dash_offset: f64,
|
||
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
|
||
let dash_lengths = dash_pattern.clamped_lengths();
|
||
let stroke = Stroke {
|
||
weight,
|
||
dash_lengths,
|
||
dash_offset,
|
||
cap,
|
||
join,
|
||
join_miter_limit: miter_limit,
|
||
align,
|
||
transform: DAffine2::IDENTITY,
|
||
paint_order,
|
||
};
|
||
|
||
let mut element = element;
|
||
for_each_interior_vector_mut(&mut element, |vector, transform| {
|
||
let mut stroke = stroke.clone();
|
||
stroke.transform *= transform;
|
||
vector.stroke = Some(stroke);
|
||
});
|
||
|
||
let paint = paint_table(paint);
|
||
let parked = park_paint(ctx.arena(), paint)?;
|
||
Ok((element, Attr(*content_transform), Attr(Some(parked))))
|
||
}
|
||
|
||
pub use _fill_graphic_leveled_mod::fill_graphic_leveled_entries;
|
||
pub use _stroke_graphic_leveled_mod::stroke_graphic_leveled_entries;
|
||
|
||
/// Each copy evaluates the content within the copy's index pushed in, placed
|
||
/// at the copy's point with its randomized scale and rotation composed onto
|
||
/// the lane transform.
|
||
#[node_macro::node(name("Copy to Points"), category("Repeat"), path(core_types::vector), extent(copy_to_points_extent))]
|
||
fn copy_to_points<T>(
|
||
ctx: impl Ctx + DeriveCtx + ExtractIndex + InjectIndex + Copy,
|
||
/// Artwork to be copied and placed at each point.
|
||
content: impl Node<Context<'_>, Output = (T, Attr<TransformAttr>)>,
|
||
/// The points to place the copies at.
|
||
#[expose]
|
||
points: IList<Vector>,
|
||
/// Minimum range of randomized sizes given to each placed copy.
|
||
#[default(1)]
|
||
#[range]
|
||
#[soft(0..2)]
|
||
#[unit("x")]
|
||
random_scale_min: Multiplier,
|
||
/// Maximum range of randomized sizes given to each placed copy.
|
||
#[default(1)]
|
||
#[range]
|
||
#[soft(0..2)]
|
||
#[unit("x")]
|
||
random_scale_max: 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: f64,
|
||
/// Seed to determine unique variations on all the randomized copy sizes.
|
||
random_scale_seed: SeedValue,
|
||
/// Range of randomized angles given to each placed copy, in degrees ranging from furthest clockwise to counterclockwise.
|
||
#[range]
|
||
#[soft(0..360)]
|
||
random_rotation: Angle,
|
||
/// Seed to determine unique variations on all the randomized copy angles.
|
||
random_rotation_seed: SeedValue,
|
||
) -> Result<IList<(T, Attr<TransformAttr>)>, Interrupt> {
|
||
let inner = content.inner_extent(ctx)?;
|
||
let (copy, rest) = ctx.split_innermost(inner);
|
||
|
||
let random_scale_difference = random_scale_max - random_scale_min;
|
||
let do_scale = random_scale_difference.abs() > 1e-6;
|
||
let do_rotation = random_rotation.abs() > 1e-6;
|
||
|
||
let mut remaining = copy as usize;
|
||
for row in 0..points.len() {
|
||
let vector = points.element_ref(row);
|
||
let positions = vector.point_domain.positions();
|
||
if remaining >= positions.len() {
|
||
remaining -= positions.len();
|
||
continue;
|
||
}
|
||
|
||
// The randomized parameters replay the row's sequential draws up to
|
||
// this copy's point.
|
||
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 mut rotation = 0.;
|
||
let mut scale = random_scale_min;
|
||
for _ in 0..=remaining {
|
||
rotation = match do_rotation {
|
||
true => (rotation_rng.random::<f64>() - 0.5) * random_rotation / 360. * TAU,
|
||
false => 0.,
|
||
};
|
||
scale = match do_scale {
|
||
false => random_scale_min,
|
||
// Linear
|
||
true if random_scale_bias.abs() < 1e-6 => random_scale_min + scale_rng.random::<f64>() * random_scale_difference,
|
||
// Weighted (see <https://www.desmos.com/calculator/gmavd3m9bd>)
|
||
true => {
|
||
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
|
||
}
|
||
};
|
||
}
|
||
|
||
let points_transform: DAffine2 = points.lane(row).attr::<TransformAttr>();
|
||
let translation = points_transform.transform_point2(positions[remaining]);
|
||
let transform = DAffine2::from_scale_angle_translation(DVec2::splat(scale), rotation, translation);
|
||
|
||
let mut frame = IndexLink { index: 0, outer: None };
|
||
let (element, local_transform) = content.eval(&ctx.push_level(&mut frame, copy, rest))?;
|
||
return Ok((element, Attr(transform * *local_transform)));
|
||
}
|
||
Err(GraphError::past_end().into())
|
||
}
|
||
|
||
/// The pushed level holds one copy per point (a data-dependent count, so the
|
||
/// points level materializes here; its cone stays small); inner levels
|
||
/// forward to the content, taken uniform across copies.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn copy_to_points_extent(
|
||
content: ExtentIn<'_>,
|
||
points: ListIn<'_, Vector>,
|
||
_random_scale_min: ValueIn<'_, f64>,
|
||
_random_scale_max: ValueIn<'_, f64>,
|
||
_random_scale_bias: ValueIn<'_, f64>,
|
||
_random_scale_seed: ValueIn<'_, SeedValue>,
|
||
_random_rotation: ValueIn<'_, f64>,
|
||
_random_rotation_seed: ValueIn<'_, SeedValue>,
|
||
level: LevelIn,
|
||
) -> GPoll<Extent> {
|
||
match level.pushed() {
|
||
true => points
|
||
.get()
|
||
.map(|points| Extent::Exactly((0..points.len()).map(|row| points.element_ref(row).point_domain.positions().len()).sum())),
|
||
false => content.at(level),
|
||
}
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn round_corners(
|
||
_: impl Ctx,
|
||
(source, transform): (Vector, Attr<TransformAttr>),
|
||
#[hard(0..)]
|
||
#[default(10.)]
|
||
radius: PixelLength,
|
||
#[range]
|
||
#[hard(0..1)]
|
||
#[default(0.5)]
|
||
roundness: f64,
|
||
#[default(100.)] edge_length_limit: Percentage,
|
||
#[range]
|
||
#[hard(0..180)]
|
||
#[default(5.)]
|
||
min_angle_threshold: Angle,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
let source_transform: DAffine2 = *transform;
|
||
let source_transform_inverse = source_transform.inverse();
|
||
|
||
// 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);
|
||
}
|
||
|
||
(result, Attr(source_transform))
|
||
}
|
||
|
||
#[node_macro::node(name("Merge by Distance"), category("Vector: Modifier"), path(core_types::vector))]
|
||
pub fn merge_by_distance(
|
||
_: impl Ctx,
|
||
(mut content, transform): (Vector, Attr<TransformAttr>),
|
||
#[default(0.1)]
|
||
#[hard(0.0001..)]
|
||
distance: PixelLength,
|
||
algorithm: MergeByDistanceAlgorithm,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
match algorithm {
|
||
MergeByDistanceAlgorithm::Spatial => content.merge_by_distance_spatial(*transform, distance),
|
||
MergeByDistanceAlgorithm::Topological => content.merge_by_distance_topological(distance),
|
||
}
|
||
(content, Attr(*transform))
|
||
}
|
||
|
||
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))]
|
||
fn extrude(_: impl Ctx, mut source: Vector, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) -> Vector {
|
||
extrude_algorithms::extrude(&mut source, direction, joining_algorithm);
|
||
source
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn box_warp(_: impl Ctx, (vector, transform): (Vector, Attr<TransformAttr>), #[expose] rectangle: IList<Vector>) -> (Vector, Attr<TransformAttr>) {
|
||
if rectangle.is_empty() {
|
||
return (vector, Attr(*transform));
|
||
}
|
||
let target = rectangle.element_ref(0);
|
||
let target_transform: DAffine2 = rectangle.lane(0).attr::<TransformAttr>();
|
||
let transform: DAffine2 = *transform;
|
||
|
||
// 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
|
||
(result, Attr(DAffine2::IDENTITY))
|
||
}
|
||
|
||
// 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), extent(pack_strips_extent))]
|
||
fn pack_strips<'e, T: BoundingBox + Clone + Send + Sync + CacheHash + 'static>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
#[implementations(Graphic, Vector, Raster<CPU>, Raster<GPU>)] elements: IList<T>,
|
||
#[default(0.)]
|
||
#[unit(" px")]
|
||
separation: f64,
|
||
#[default(1000.)]
|
||
#[unit(" px")]
|
||
strip_max_length: f64,
|
||
strip_direction: RowsOrColumns,
|
||
) -> Result<IList<(Lane<T>, Attr<'e, TransformAttr>)>, Interrupt> {
|
||
// Best-Fit Decreasing Height: sort by cross-axis size, then place each item on
|
||
// the strip with the least remaining space that still fits it.
|
||
struct Strip {
|
||
along_position: f64,
|
||
cross_position: f64,
|
||
cross_extent: f64,
|
||
}
|
||
|
||
let lane = ctx.innermost_index() as usize;
|
||
if lane >= elements.len() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
|
||
let mut items: Vec<(f64, f64, DVec2, usize)> = (0..elements.len())
|
||
.map(|row| {
|
||
// The pre-flip single-item `List` wrap composed the item's own
|
||
// transform into its bounds.
|
||
let lane_transform: DAffine2 = elements.lane(row).attr::<TransformAttr>();
|
||
let (width, height, top_left) = match elements.element_ref(row).bounding_box(lane_transform, false) {
|
||
RenderBoundingBox::Rectangle([min, max]) => {
|
||
let size = max - min;
|
||
(size.x.max(0.), size.y.max(0.), min)
|
||
}
|
||
_ => (0., 0., DVec2::ZERO),
|
||
};
|
||
match strip_direction {
|
||
RowsOrColumns::Rows => (width, height, top_left, row),
|
||
RowsOrColumns::Columns => (height, width, top_left, row),
|
||
}
|
||
})
|
||
.collect();
|
||
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));
|
||
|
||
let mut strips: Vec<Strip> = Vec::new();
|
||
let mut gathered = (lane, DAffine2::IDENTITY);
|
||
|
||
for (position, &(along, cross, top_left, source)) in items.iter().enumerate() {
|
||
let lane_transform: DAffine2 = elements.lane(source).attr::<TransformAttr>();
|
||
if along <= 0. {
|
||
if position == lane {
|
||
gathered = (source, lane_transform);
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// n*k where k is the strip count, generally much smaller than n
|
||
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);
|
||
}
|
||
}
|
||
|
||
let target_position = match best_strip_index {
|
||
Some(strip_index) => {
|
||
let strip = &mut strips[strip_index];
|
||
if cross > strip.cross_extent {
|
||
strip.cross_extent = cross;
|
||
}
|
||
let target = match strip_direction {
|
||
RowsOrColumns::Rows => DVec2::new(strip.along_position, strip.cross_position),
|
||
RowsOrColumns::Columns => DVec2::new(strip.cross_position, strip.along_position),
|
||
};
|
||
strip.along_position += along + separation;
|
||
target
|
||
}
|
||
None => {
|
||
let new_cross = strips.last().map_or(0., |last| last.cross_position + last.cross_extent + separation);
|
||
let target = match strip_direction {
|
||
RowsOrColumns::Rows => DVec2::new(0., new_cross),
|
||
RowsOrColumns::Columns => DVec2::new(new_cross, 0.),
|
||
};
|
||
strips.push(Strip {
|
||
along_position: along + separation,
|
||
cross_position: new_cross,
|
||
cross_extent: cross,
|
||
});
|
||
target
|
||
}
|
||
};
|
||
|
||
if position == lane {
|
||
gathered = (source, DAffine2::from_translation(target_position - top_left) * lane_transform);
|
||
break;
|
||
}
|
||
}
|
||
|
||
let (source, placement) = gathered;
|
||
Ok((elements.lane(source), Attr(placement)))
|
||
}
|
||
|
||
fn pack_strips_extent<T>(elements: ListIn<'_, T>, _separation: ValueIn<'_, f64>, _strip_max_length: ValueIn<'_, f64>, _strip_direction: ValueIn<'_, RowsOrColumns>, level: LevelIn) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => elements.total(),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
/// 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))]
|
||
fn auto_tangents(
|
||
_: impl Ctx,
|
||
(source, lane_transform): (Vector, Attr<TransformAttr>),
|
||
/// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread).
|
||
#[default(0.5)]
|
||
#[range]
|
||
#[soft(0..1)]
|
||
spread: f64,
|
||
/// If active, existing non-zero handles won't be affected.
|
||
#[default(true)]
|
||
preserve_existing: bool,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
let transform: DAffine2 = *lane_transform;
|
||
|
||
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])]);
|
||
}
|
||
}
|
||
}
|
||
|
||
(result, Attr(transform))
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn bounding_box(_: impl Ctx, vector: Vector) -> Vector {
|
||
let mut result = vector
|
||
.bounding_box_rect()
|
||
.map(|bbox| {
|
||
let mut vector = Vector::default();
|
||
vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY));
|
||
vector
|
||
})
|
||
.unwrap_or_default();
|
||
|
||
result.stroke = vector.stroke.clone();
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
result
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
fn dimensions(_: impl Ctx, content: IList<Vector>) -> DVec2 {
|
||
(0..content.len())
|
||
.filter_map(|index| content.element_ref(index).bounding_box_with_transform(content.lane(index).attr::<TransformAttr>()))
|
||
.reduce(|[acc_top_left, acc_bottom_right], [top_left, bottom_right]| [acc_top_left.min(top_left), acc_bottom_right.max(bottom_right)])
|
||
.map(|[top_left, bottom_right]| bottom_right - top_left)
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Type-asserts a value to be vector data. A position becomes a single-anchor vector.
|
||
#[node_macro::node(category("Vector"), name("As Vector"), path(core_types::vector))]
|
||
fn as_vector<T: Into<Vector>>(_: impl Ctx, #[implementations(Vector, DVec2)] value: T) -> Vector {
|
||
value.into()
|
||
}
|
||
|
||
/// 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))]
|
||
fn points_to_polyline(_: impl Ctx, mut points: Vector, #[default(true)] closed: bool) -> Vector {
|
||
let mut segment_domain = SegmentDomain::new();
|
||
let mut next_id = SegmentId::ZERO;
|
||
|
||
let points_count = points.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());
|
||
|
||
points
|
||
.region_domain
|
||
.push(RegionId::generate(), segment_domain.ids()[0]..=*segment_domain.ids().last().unwrap(), FillId::generate());
|
||
}
|
||
}
|
||
|
||
points.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))]
|
||
fn relax_points(
|
||
_: impl Ctx,
|
||
/// A vector path or point cloud to relax.
|
||
mut source: 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: f64,
|
||
) -> Vector {
|
||
let relaxed = crate::voronoi::relax_sites(source.point_domain.positions(), iterations);
|
||
for ((_, position), new_position) in source.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))]
|
||
fn voronoi_cells(_: impl Ctx, mut source: Vector, connect_cells: bool) -> Vector {
|
||
let sites = source.point_domain.positions().to_vec();
|
||
let cells = crate::voronoi::voronoi_cells(&sites);
|
||
if !cells.is_empty() {
|
||
replace_with_polygons(&mut source, 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))]
|
||
fn triangulate(_: impl Ctx, mut source: Vector, connect_cells: bool) -> Vector {
|
||
let sites = source.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(&mut source, 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"))]
|
||
fn offset_path(_: impl Ctx, (vector, lane_transform): (Vector, Attr<TransformAttr>), distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> (Vector, Attr<TransformAttr>) {
|
||
let transform_attribute: DAffine2 = *lane_transform;
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
|
||
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);
|
||
}
|
||
|
||
(result, Attr(transform_attribute))
|
||
}
|
||
|
||
fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
|
||
// TODO: Make this node support stroke align, which it currently ignores
|
||
|
||
// A fill exists when the canonical attribute carries paint
|
||
let has_fills: Vec<bool> = (0..flattened.len()).map(|index| has_paint::<Fill, _>(&flattened, index)).collect();
|
||
|
||
let 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::<Option<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::<Option<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();
|
||
|
||
output
|
||
}
|
||
|
||
/// One output lane of the solidify: the walk locates the flattened row the
|
||
/// lane addresses (a fill-bearing row serves two lanes), builds and splits
|
||
/// only that row, and lane 0 additionally carries the merged-layers snapshot.
|
||
#[allow(clippy::type_complexity)]
|
||
fn solidify_native_lane<'e>(
|
||
arena: &'e core_types::arena::Arena,
|
||
level: graphic_types::graphic::GraphicLevel<'_>,
|
||
snapshot: impl FnOnce() -> List<Graphic<'static>>,
|
||
lane: usize,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
use graphic_types::graphic::RowStep;
|
||
let mut remaining = lane;
|
||
let mut located: Option<List<Vector>> = None;
|
||
graphic_types::graphic::walk_vector_rows(level, &mut |row| {
|
||
let parts = 1 + row.has_fill() as usize;
|
||
if remaining >= parts {
|
||
remaining -= parts;
|
||
return RowStep::Continue;
|
||
}
|
||
let mut one = List::new();
|
||
row.build_into(&mut one);
|
||
located = Some(one);
|
||
RowStep::Stop
|
||
});
|
||
let Some(row) = located else {
|
||
return Err(GraphError::past_end().into());
|
||
};
|
||
let mut split = solidify_rows(row);
|
||
// 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 lane == 0 && !split.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 = snapshot();
|
||
let row_0_transform: DAffine2 = split.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;
|
||
}
|
||
}
|
||
|
||
split.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, Some(graphic_list));
|
||
}
|
||
emit_legacy_lane(arena, split, remaining)
|
||
}
|
||
|
||
/// One lane of a legacy result list as the element and standard-attribute
|
||
/// tuple a fold kernel emits.
|
||
#[allow(clippy::type_complexity)]
|
||
fn emit_legacy_lane<'e>(
|
||
arena: &'e core_types::arena::Arena,
|
||
output: List<Vector>,
|
||
lane: usize,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
if lane >= output.len() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
let exhausted = || {
|
||
Interrupt::from(GraphError {
|
||
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
|
||
trace: Vec::new(),
|
||
})
|
||
};
|
||
|
||
let element = output.element(lane).cloned().unwrap_or_default();
|
||
let fill = output
|
||
.attribute::<Option<List<Graphic>>>(ATTR_FILL, lane)
|
||
.and_then(|paint| paint.as_ref())
|
||
.map(|paint| park_paint(arena, paint.clone()))
|
||
.transpose()?;
|
||
let stroke = output
|
||
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, lane)
|
||
.and_then(|paint| paint.as_ref())
|
||
.map(|paint| park_paint(arena, paint.clone()))
|
||
.transpose()?;
|
||
let layer_path: Vec<NodeId> = output.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, lane).cloned().unwrap_or_default();
|
||
let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0;
|
||
let merged_layers = output
|
||
.attribute::<Option<List<Graphic>>>(ATTR_EDITOR_MERGED_LAYERS, lane)
|
||
.and_then(|layers| layers.as_ref())
|
||
.map(|layers| arena.alloc_sized_keyed(layers.clone(), 0).ok_or_else(exhausted).map(|(parked, _)| parked))
|
||
.transpose()?;
|
||
|
||
Ok((
|
||
element,
|
||
Attr(output.attribute_cloned_or_default(ATTR_TRANSFORM, lane)),
|
||
Attr(fill),
|
||
Attr(stroke),
|
||
Attr(output.attribute_cloned_or_default(ATTR_BLEND_MODE, lane)),
|
||
Attr(output.attribute_cloned_or(ATTR_OPACITY, lane, 1.)),
|
||
Attr(output.attribute_cloned_or(ATTR_OPACITY_FILL, lane, 1.)),
|
||
Attr(output.attribute_cloned_or_default(ATTR_CLIPPING_MASK, lane)),
|
||
Attr(layer_path.as_slice()),
|
||
Attr(merged_layers),
|
||
))
|
||
}
|
||
|
||
/// The wrap the legacy list collapse applied to a vector level: the run as
|
||
/// one group lane, lane 0's layer path stamped on the wrapper.
|
||
fn wrap_vector_level(content: core_types::node::List<'_, Vector>) -> List<Graphic<'_>> {
|
||
let item = content.as_group_item();
|
||
let layer_path: Vec<NodeId> = match !content.is_empty() {
|
||
true => content.lane(0).attr::<EditorLayerPath>().to_vec(),
|
||
false => Vec::new(),
|
||
};
|
||
let mut wrapper = List::new_from_element(Graphic::Group(core_types::record::Group { row: None, content: item }));
|
||
if !layer_path.is_empty() {
|
||
wrapper.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
|
||
}
|
||
wrapper
|
||
}
|
||
|
||
/// The materialized level as the legacy graphic list the editor-facing
|
||
/// merged-layers snapshots carry.
|
||
fn legacy_graphic_list_of<T: dyn_any::StaticTypeSized>(content: core_types::node::List<'_, T>) -> List<Graphic<'static>>
|
||
where
|
||
T::Static: Clone + Send + Sync + dyn_any::StaticTypeSized,
|
||
List<T::Static>: IntoGraphicList,
|
||
{
|
||
let item = content.as_group_item();
|
||
graphic_types::graphic::run_to_list::<T::Static>(&item)
|
||
.expect("the run holds the row's element type")
|
||
.into_graphic_list()
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), extent(solidify_stroke_extent))]
|
||
fn solidify_stroke<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Graphic<'static>>,
|
||
) -> Result<
|
||
IList<(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
)>,
|
||
Interrupt,
|
||
> {
|
||
let item = content.as_group_item();
|
||
solidify_native_lane(ctx.arena(), graphic_types::graphic::GraphicLevel::Run(&item), || legacy_graphic_list_of(content), ctx.index() as usize)
|
||
}
|
||
|
||
/// A fill-bearing row splits into a fill lane and a solidified stroke lane,
|
||
/// so the count depends on the content: the level reports the subject's
|
||
/// count as a lower bound and consumers drain to the past-end signal.
|
||
fn solidify_stroke_extent(content: ListIn<'_, Graphic>, level: LevelIn) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total().map(|total| {
|
||
Extent::AtLeast(match total {
|
||
Extent::Exactly(count) | Extent::AtLeast(count) => count,
|
||
Extent::Free => 0,
|
||
})
|
||
}),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
/// The solidify over a plain vector level, as [`solidify_stroke`].
|
||
/// Registered under the solidify identifier.
|
||
#[node_macro::node(category(""), extent(solidify_stroke_vector_extent))]
|
||
fn solidify_stroke_vector<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Vector>,
|
||
) -> Result<
|
||
IList<(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
)>,
|
||
Interrupt,
|
||
> {
|
||
let wrapper = wrap_vector_level(content);
|
||
solidify_native_lane(
|
||
ctx.arena(),
|
||
graphic_types::graphic::GraphicLevel::Legacy(&wrapper),
|
||
|| legacy_graphic_list_of(content),
|
||
ctx.index() as usize,
|
||
)
|
||
}
|
||
|
||
fn solidify_stroke_vector_extent(content: ListIn<'_, Vector>, level: LevelIn) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total().map(|total| {
|
||
Extent::AtLeast(match total {
|
||
Extent::Exactly(count) | Extent::AtLeast(count) => count,
|
||
Extent::Free => 0,
|
||
})
|
||
}),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
pub use _solidify_stroke_vector_mod::solidify_stroke_vector_entries;
|
||
|
||
fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
|
||
content
|
||
.into_iter()
|
||
.flat_map(|row| {
|
||
let bezpaths = row.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 vec![row];
|
||
}
|
||
|
||
let stroke = row.element().stroke.clone();
|
||
let (_, attributes) = row.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::<Vec<Item<Vector>>>()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Splits each vector element into one element per subpath, keeping the source element's attributes on every split-off lane.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), extent(separate_subpaths_extent))]
|
||
fn separate_subpaths<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Vector>,
|
||
) -> Result<
|
||
IList<(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
)>,
|
||
Interrupt,
|
||
> {
|
||
let output = separate_subpaths_core(vector_rows_of(content));
|
||
emit_legacy_lane(ctx.arena(), output, ctx.index() as usize)
|
||
}
|
||
|
||
/// A row splits into one lane per subpath, so the count depends on the
|
||
/// content: the level reports the subject's count as a lower bound and
|
||
/// consumers drain to the past-end signal.
|
||
fn separate_subpaths_extent(content: ListIn<'_, Vector>, level: LevelIn) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total().map(|total| {
|
||
Extent::AtLeast(match total {
|
||
Extent::Exactly(count) | Extent::AtLeast(count) => count,
|
||
Extent::Free => 0,
|
||
})
|
||
}),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
/// Determines if the subpath at the given index (across all vector element subpaths) 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))]
|
||
fn path_is_closed(
|
||
_: impl Ctx,
|
||
/// The vector content whose subpaths are inspected.
|
||
content: IList<Vector>,
|
||
/// The index of the subpath to check, counting across subpaths in all vector elements.
|
||
index: f64,
|
||
) -> bool {
|
||
(0..content.len())
|
||
.flat_map(|row| content.element_ref(row).build_stroke_path_iter().map(|(_, closed)| closed))
|
||
.nth(index.max(0.) as usize)
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// Sets each anchor point's position to the value the mapped input produces, with the point's
|
||
/// index and current position provided via context.
|
||
#[node_macro::node(category("Vector"), path(graphene_core::vector), extent(map_points_extent))]
|
||
fn map_points<'e>(
|
||
ctx: impl Ctx + DeriveCtx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Vector>,
|
||
mapped: impl Node<Context<'_>, Output = DVec2>,
|
||
) -> Result<
|
||
IList<(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
)>,
|
||
Interrupt,
|
||
> {
|
||
// The pushed copy keeps the legacy convention: the running point index
|
||
// across all rows rides as a promotion for the mapped input.
|
||
let spilled = ctx.index_head();
|
||
let mut content = vector_rows_of(content);
|
||
let mut index = 0;
|
||
|
||
for vector in content.iter_element_values_mut() {
|
||
for (_, position) in vector.point_domain.positions_mut() {
|
||
let scoped = ctx.push_position(*position);
|
||
*position = mapped.eval(&scoped.ctx().promoted(&spilled, index))?;
|
||
index += 1;
|
||
}
|
||
}
|
||
|
||
emit_legacy_lane(ctx.arena(), content, ctx.index() as usize)
|
||
}
|
||
|
||
fn map_points_extent(content: ListIn<'_, Vector>, _mapped: ExtentIn<'_>, level: LevelIn) -> GPoll<Extent> {
|
||
subject_counts_extent(content, level)
|
||
}
|
||
|
||
/// The level's flattened rows paired with the top-level lane each descends
|
||
/// from, so a merge can name the input lane whose columns its result carries.
|
||
fn flatten_rows_with_top_lanes(level: graphic_types::graphic::GraphicLevel<'_>) -> (List<Vector>, Vec<usize>) {
|
||
let mut rows = List::new();
|
||
let mut tops = Vec::new();
|
||
graphic_types::graphic::walk_vector_rows(level, &mut |row| {
|
||
tops.push(row.top_lane());
|
||
row.build_into(&mut rows);
|
||
graphic_types::graphic::RowStep::Continue
|
||
});
|
||
(rows, tops)
|
||
}
|
||
|
||
/// Also reports the flattened row the merge took its paint and layer from, so
|
||
/// the caller can carry that row's top-level lane.
|
||
#[allow(clippy::type_complexity)]
|
||
fn flatten_path_core<'e>(
|
||
arena: &'e core_types::arena::Arena,
|
||
flattened: List<Vector>,
|
||
snapshot: List<Graphic<'static>>,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
Option<usize>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
let mut output = Vector::default();
|
||
let mut primary_source = None;
|
||
|
||
// 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: Vec<NodeId> = flattened.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index);
|
||
let node_id = layer_path.last().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));
|
||
}
|
||
|
||
let mut fill = None;
|
||
let mut stroke = None;
|
||
let mut layer_path = Vec::new();
|
||
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);
|
||
bake_paint_transforms(&mut attributes, source_transform);
|
||
|
||
let carrier = List::new_from_item(Item::from_parts(Vector::default(), attributes));
|
||
fill = carrier
|
||
.attribute::<Option<List<Graphic>>>(ATTR_FILL, 0)
|
||
.and_then(|paint| paint.as_ref())
|
||
.map(|paint| park_paint(arena, paint.clone()))
|
||
.transpose()?;
|
||
stroke = carrier
|
||
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, 0)
|
||
.and_then(|paint| paint.as_ref())
|
||
.map(|paint| park_paint(arena, paint.clone()))
|
||
.transpose()?;
|
||
|
||
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
|
||
layer_path = flattened.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, primary);
|
||
}
|
||
let exhausted = || {
|
||
Interrupt::from(GraphError {
|
||
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
|
||
trace: Vec::new(),
|
||
})
|
||
};
|
||
let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0;
|
||
// Snapshot the input layers so the renderer can recurse into them for
|
||
// editor click-target preservation, as the boolean operation does.
|
||
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
|
||
|
||
Ok((
|
||
output,
|
||
Attr(DAffine2::IDENTITY),
|
||
Attr(fill),
|
||
Attr(stroke),
|
||
Attr(layer_path.as_slice()),
|
||
Attr(Some(merged_layers)),
|
||
primary_source.map(|(index, _)| index),
|
||
))
|
||
}
|
||
|
||
// TODO: Make this happen per-element instead of flattening every element into a single path. The migration for this should then become a Flatten Vector -> Combine Paths pair of nodes.
|
||
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
|
||
pub fn combine_paths<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Graphic<'static>>,
|
||
) -> Result<
|
||
(
|
||
Lane<Vector>,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
if content.is_empty() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
let item = content.as_group_item();
|
||
let (flattened, tops) = flatten_rows_with_top_lanes(graphic_types::graphic::GraphicLevel::Run(&item));
|
||
let snapshot = graphic_types::graphic::run_to_list::<Graphic>(&item).expect("the run holds the row's element type");
|
||
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
|
||
// The merge presents the blending of the top-level row its last contributing
|
||
// path came from, carried rather than re-read. The layer path stays an
|
||
// override: it deliberately names the contributing CHILD layer, not the row.
|
||
let carrier = primary.and_then(|row| tops.get(row).copied()).unwrap_or(0);
|
||
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
|
||
}
|
||
|
||
/// The path flattening over a plain vector level, as [`combine_paths`].
|
||
/// Registered under the combine paths identifier.
|
||
#[node_macro::node(category(""))]
|
||
pub fn combine_paths_vector<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Vector>,
|
||
) -> Result<
|
||
(
|
||
Lane<Vector>,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
if content.is_empty() {
|
||
return Err(GraphError::past_end().into());
|
||
}
|
||
let wrapper = wrap_vector_level(content);
|
||
let flattened = graphic_types::graphic::flatten_vector_rows(graphic_types::graphic::GraphicLevel::Legacy(&wrapper));
|
||
let snapshot = legacy_graphic_list_of(content);
|
||
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
|
||
// `top_lane` is degenerate here - the wrapper is one graphic lane holding the
|
||
// whole vector run, so every row reports 0. The rows ARE the input lanes in
|
||
// order though, so the contributing row index names the lane directly.
|
||
let carrier = primary.filter(|row| *row < content.len()).unwrap_or(0);
|
||
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
|
||
}
|
||
|
||
pub use _combine_paths_vector_mod::combine_paths_vector_entries;
|
||
|
||
/// 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)]
|
||
fn sample_polyline(
|
||
_: impl Ctx,
|
||
(element, transform): (Vector, Attr<TransformAttr>),
|
||
spacing: PointSpacingType,
|
||
#[default(100.)]
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
separation: f64,
|
||
#[default(100)]
|
||
#[hard(2..)]
|
||
quantity: u32,
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
start_offset: f64,
|
||
#[hard(0..)]
|
||
#[unit(" px")]
|
||
stop_offset: f64,
|
||
adaptive_spacing: bool,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
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 element = element;
|
||
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 element.stroke),
|
||
};
|
||
// Transfer the stroke transform from the input vector content to the result.
|
||
result.set_stroke_transform(*transform);
|
||
|
||
for local_bezpath in 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();
|
||
world_bezpath.apply_affine(Affine::new(transform.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);
|
||
}
|
||
|
||
(result, Attr(*transform))
|
||
}
|
||
|
||
/// 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))]
|
||
fn simplify(
|
||
_: impl Ctx,
|
||
/// The vector paths to simplify.
|
||
(content, lane_transform): (Vector, Attr<TransformAttr>),
|
||
/// The maximum distance the simplified path may deviate from the original.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
tolerance: Length,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
if tolerance <= 0. {
|
||
return (content, Attr(*lane_transform));
|
||
}
|
||
|
||
let options = SimplifyOptions::default();
|
||
|
||
let transform_attribute: DAffine2 = *lane_transform;
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
let inverse_transform = transform.inverse();
|
||
|
||
let mut result = Vector {
|
||
stroke: content.stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
for mut bezpath in content.stroke_bezpath_iter() {
|
||
bezpath.apply_affine(transform);
|
||
|
||
let mut simplified = simplify_bezpath(bezpath, tolerance, &options);
|
||
|
||
simplified.apply_affine(inverse_transform);
|
||
result.append_bezpath(simplified);
|
||
}
|
||
|
||
(result, Attr(transform_attribute))
|
||
}
|
||
|
||
/// 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))]
|
||
fn decimate(
|
||
_: impl Ctx,
|
||
/// The vector paths to decimate.
|
||
(content, lane_transform): (Vector, Attr<TransformAttr>),
|
||
/// The maximum distance a point can deviate from the simplified path before it is kept.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
tolerance: Length,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
// Tolerance of 0 means no simplification is possible, so return immediately
|
||
if tolerance <= 0. {
|
||
return (content, Attr(*lane_transform));
|
||
}
|
||
|
||
// 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 = *lane_transform;
|
||
let transform = Affine::new(transform_attribute.to_cols_array());
|
||
let inverse_transform = transform.inverse();
|
||
|
||
let mut result = Vector {
|
||
stroke: content.stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
for mut bezpath in content.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);
|
||
}
|
||
|
||
(result, Attr(transform_attribute))
|
||
}
|
||
|
||
/// The materialized vector level as the owned rows the cross-lane cores walk,
|
||
/// content kept native.
|
||
fn vector_rows_of(content: core_types::node::List<'_, Vector>) -> List<Vector> {
|
||
let item = content.as_group_item();
|
||
graphic_types::graphic::run_to_list::<Vector>(&item).expect("the run holds vector lanes")
|
||
}
|
||
|
||
/// A count-preserving cross-lane node's extent: the subject's own counts.
|
||
fn subject_counts_extent(content: ListIn<'_, Vector>, level: LevelIn) -> GPoll<Extent> {
|
||
match level.top() {
|
||
true => content.total(),
|
||
false => GPoll::Final(Extent::Exactly(1)),
|
||
}
|
||
}
|
||
|
||
fn cut_path_core(mut content: List<Vector>, progression: f64, reverse: bool, parameterized_distance: bool) -> List<Vector> {
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let bezpaths = content
|
||
.iter_element_values()
|
||
.enumerate()
|
||
.flat_map(|(row_index, vector)| vector.stroke_bezpath_iter().map(|bezpath| (row_index, bezpath)).collect::<Vec<_>>())
|
||
.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((row_index, bezpath)) = bezpaths.get(index).cloned() {
|
||
let mut result_vector = Vector {
|
||
stroke: content.element(row_index).unwrap().stroke.clone(),
|
||
..Default::default()
|
||
};
|
||
|
||
for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_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(row_index).unwrap() = result_vector;
|
||
}
|
||
|
||
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), extent(cut_path_extent))]
|
||
fn cut_path<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
/// The path to insert a cut into.
|
||
content: IList<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: Progression,
|
||
/// Swap the direction of the path.
|
||
reverse: 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: bool,
|
||
) -> Result<
|
||
IList<(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
)>,
|
||
Interrupt,
|
||
> {
|
||
let output = cut_path_core(vector_rows_of(content), progression, reverse, parameterized_distance);
|
||
emit_legacy_lane(ctx.arena(), output, ctx.index() as usize)
|
||
}
|
||
|
||
fn cut_path_extent(content: ListIn<'_, Vector>, _progression: ValueIn<'_, f64>, _reverse: ValueIn<'_, bool>, _parameterized_distance: ValueIn<'_, bool>, level: LevelIn) -> GPoll<Extent> {
|
||
subject_counts_extent(content, level)
|
||
}
|
||
|
||
/// Cuts path segments into separate disconnected pieces where each is a distinct subpath.
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn cut_segments(_: impl Ctx, mut content: Vector) -> Vector {
|
||
// Make a copy of each segment's endpoints, then reassign each segment's endpoints to its own unique point copy
|
||
{
|
||
let vector = &mut content;
|
||
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))]
|
||
fn position_on_path(
|
||
_: impl Ctx,
|
||
/// The path to traverse.
|
||
content: IList<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: Progression,
|
||
/// Swap the direction of the path.
|
||
reverse: 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: bool,
|
||
) -> DVec2 {
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let mut bezpaths: Vec<_> = (0..content.len())
|
||
.flat_map(|index| {
|
||
let transform: DAffine2 = content.lane(index).attr::<TransformAttr>();
|
||
content.element_ref(index).stroke_bezpath_iter().map(move |bezpath| (bezpath, transform)).collect::<Vec<_>>()
|
||
})
|
||
.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 };
|
||
|
||
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))
|
||
})
|
||
}
|
||
|
||
/// 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))]
|
||
fn tangent_on_path(
|
||
_: impl Ctx,
|
||
/// The path to traverse.
|
||
content: IList<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: Progression,
|
||
/// Swap the direction of the path.
|
||
reverse: 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: bool,
|
||
/// Whether the resulting angle should be given in as radians instead of degrees.
|
||
radians: bool,
|
||
) -> f64 {
|
||
let euclidian = !parameterized_distance;
|
||
|
||
let mut bezpaths: Vec<_> = (0..content.len())
|
||
.flat_map(|index| {
|
||
let transform: DAffine2 = content.lane(index).attr::<TransformAttr>();
|
||
content.element_ref(index).stroke_bezpath_iter().map(move |bezpath| (bezpath, transform)).collect::<Vec<_>>()
|
||
})
|
||
.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 })
|
||
});
|
||
|
||
if radians { angle } else { angle.to_degrees() }
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector), memoize)]
|
||
fn scatter_points(
|
||
_: impl Ctx,
|
||
element: Vector,
|
||
#[unit(" px")]
|
||
#[default(10.)]
|
||
#[range]
|
||
#[hard(0.01..)]
|
||
#[soft(1..100)]
|
||
separation: f64,
|
||
seed: SeedValue,
|
||
) -> Vector {
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
|
||
let mut result = Vector::default();
|
||
|
||
let path_with_bounding_boxes: Vec<_> = 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 = element.stroke.clone();
|
||
result.set_stroke_transform(DAffine2::IDENTITY);
|
||
|
||
result
|
||
}
|
||
|
||
#[node_macro::node(name("Spline"), category("Vector: Modifier"), path(core_types::vector))]
|
||
fn spline(_: impl Ctx, element: Vector) -> Vector {
|
||
// Exit early if there are no points to generate splines from.
|
||
if element.point_domain.positions().is_empty() {
|
||
return element;
|
||
}
|
||
|
||
let mut segment_domain = SegmentDomain::default();
|
||
let mut next_id = SegmentId::ZERO;
|
||
for (manipulator_groups, closed) in 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 = element.point_domain.resolve_id(manipulator_groups[i].id).unwrap();
|
||
let end_index = 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);
|
||
}
|
||
}
|
||
|
||
let mut element = element;
|
||
element.segment_domain = segment_domain;
|
||
element
|
||
}
|
||
|
||
/// 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))]
|
||
fn jitter_points(
|
||
_: impl Ctx,
|
||
/// The vector geometry with points to be jittered.
|
||
(element, transform): (Vector, Attr<TransformAttr>),
|
||
/// The maximum extent of the random distance each point can be offset.
|
||
#[default(5.)]
|
||
#[unit(" px")]
|
||
max_distance: f64,
|
||
/// Seed used to determine unique variations on all randomized offsets.
|
||
seed: SeedValue,
|
||
/// Whether to offset anchor points along their normal direction (perpendicular to the path) or in a random direction. Free-floating and branching points have no normal direction, so they receive a random-angled offset regardless of this setting.
|
||
#[default(true)]
|
||
along_normals: bool,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
|
||
let inverse_linear = inverse_linear_or_repair(transform.matrix2);
|
||
|
||
let deltas: Vec<_> = (0..element.point_domain.positions().len())
|
||
.map(|point_index| {
|
||
let normal = if along_normals {
|
||
element.segment_domain.point_tangent(point_index, 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 mut element = element;
|
||
apply_point_deltas(&mut element, &deltas, *transform);
|
||
|
||
(element, Attr(*transform))
|
||
}
|
||
|
||
/// 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))]
|
||
fn offset_points(
|
||
_: impl Ctx,
|
||
/// The vector geometry with points to be offset.
|
||
(mut element, transform): (Vector, Attr<TransformAttr>),
|
||
/// The distance to offset each anchor point along its normal. Positive values move outward, negative values move inward.
|
||
#[default(10.)]
|
||
#[unit(" px")]
|
||
distance: f64,
|
||
) -> (Vector, Attr<TransformAttr>) {
|
||
let inverse_linear = inverse_linear_or_repair(transform.matrix2);
|
||
|
||
let deltas: Vec<_> = (0..element.point_domain.positions().len())
|
||
.map(|point_index| {
|
||
let Some(normal) = element.segment_domain.point_tangent(point_index, element.point_domain.positions()).map(|t| -t.perp()) else {
|
||
return DVec2::ZERO;
|
||
};
|
||
|
||
inverse_linear * normal * distance
|
||
})
|
||
.collect();
|
||
|
||
apply_point_deltas(&mut element, &deltas, *transform);
|
||
|
||
(element, Attr(*transform))
|
||
}
|
||
|
||
/// 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.
|
||
fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progression: f64, reverse: bool, distribution: InterpolationDistribution, path: List<Vector>) -> List<Vector> {
|
||
use core_types::lane::LaneSource;
|
||
/// 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(paint_a: &List<Graphic>, paint_b: &List<Graphic>, time: f64) -> DAffine2 {
|
||
let transform_a = paint_a.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0);
|
||
let transform_b = paint_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<'static>>>, b: Option<&List<Graphic<'static>>>, time: f64) -> Option<List<Graphic<'static>>> {
|
||
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, which ride the paint lane
|
||
let gradient_paint = |metadata_source: &List<Graphic>, stops: Gradient, transform: Option<DAffine2>| -> List<Graphic> {
|
||
let mut out = List::new_from_item(Item::from_parts(Graphic::Gradient(stops), metadata_source.clone_item_attributes(0)));
|
||
if let Some(transform) = transform {
|
||
out.set_attribute(ATTR_TRANSFORM, 0, transform);
|
||
}
|
||
out
|
||
};
|
||
|
||
match (a.element(0), b.element(0)) {
|
||
(Some(Graphic::Color(color_a)), Some(Graphic::Color(color_b))) => Some(List::new_from_element(Graphic::from(color_a.lerp(color_b, time as f32)))),
|
||
(Some(Graphic::Color(color_a)), Some(Graphic::Gradient(stops_b))) => {
|
||
let solid_to_gradient = stops_b.map_colors(|_| *color_a);
|
||
let stops = solid_to_gradient.lerp(stops_b, time);
|
||
Some(gradient_paint(b, stops, None))
|
||
}
|
||
(Some(Graphic::Gradient(stops_a)), Some(Graphic::Color(color_b))) => {
|
||
let gradient_to_solid = stops_a.map_colors(|_| *color_b);
|
||
let stops = stops_a.lerp(&gradient_to_solid, time);
|
||
Some(gradient_paint(a, stops, None))
|
||
}
|
||
(Some(Graphic::Gradient(stops_a)), Some(Graphic::Gradient(stops_b))) => {
|
||
let stops = stops_a.lerp(stops_b, time);
|
||
let metadata_source = if time < 0.5 { a } else { b };
|
||
Some(gradient_paint(metadata_source, stops, Some(lerp_gradient_transform(a, b, time))))
|
||
}
|
||
// Pairings beyond solid colors and gradients (raster, vector, or mixed) can't be interpolated, so step at the midpoint
|
||
_ => Some(if time < 0.5 { a.clone() } else { b.clone() }),
|
||
}
|
||
}
|
||
|
||
// Preserve the original legacy snapshot as upstream data so this group layer's nested layers can be edited by the tools.
|
||
let mut graphic_list_content = snapshot;
|
||
|
||
let content = flattened;
|
||
|
||
// Not enough elements to interpolate between, so we return the input as-is
|
||
if content.len() <= 1 {
|
||
return content;
|
||
}
|
||
|
||
// 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> = if path.is_empty() {
|
||
default_polyline()
|
||
} else {
|
||
// User-provided path: collect all subpaths with transforms applied
|
||
let paths: Vec<BezPath> = (0..path.len())
|
||
.flat_map(|index| {
|
||
let transform: DAffine2 = path.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||
path.element(index)
|
||
.unwrap()
|
||
.stroke_bezpath_iter()
|
||
.map(move |mut bezpath| {
|
||
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
|
||
bezpath
|
||
})
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.collect();
|
||
|
||
// Fall back to default polyline if the user-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().into_iter().collect();
|
||
}
|
||
|
||
// 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 content;
|
||
};
|
||
|
||
// 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, Some(graphic_list_content));
|
||
|
||
return List::new_from_item(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 = content.attr::<Fill>(source_index).filter(|paint| is_paint_present(paint));
|
||
let target = content.attr::<Fill>(target_index).filter(|paint| is_paint_present(paint));
|
||
lerp_graphic(source, target, time)
|
||
};
|
||
let stroke_paint = {
|
||
let source = content.attr::<StrokeAttr>(source_index).filter(|paint| is_paint_present(paint));
|
||
let target = content.attr::<StrokeAttr>(target_index).filter(|paint| is_paint_present(paint));
|
||
lerp_graphic(source, target, 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 layer_path: Vec<NodeId> = content.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, primary_index);
|
||
|
||
let mut item = Item::new_from_element(vector)
|
||
.with_attribute(ATTR_TRANSFORM, lerped_transform)
|
||
.with_attribute(ATTR_BLEND_MODE, lerped_blend_mode)
|
||
.with_attribute(ATTR_OPACITY, lerped_opacity)
|
||
.with_attribute(ATTR_OPACITY_FILL, lerped_fill)
|
||
.with_attribute(ATTR_CLIPPING_MASK, lerped_clip)
|
||
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
|
||
.with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content));
|
||
|
||
if let Some(fill) = fill_paint {
|
||
item.set_attribute(ATTR_FILL, Some(fill));
|
||
}
|
||
if let Some(stroke) = stroke_paint {
|
||
item.set_attribute(ATTR_STROKE, Some(stroke));
|
||
}
|
||
|
||
List::new_from_item(item)
|
||
}
|
||
|
||
/// The morph over its legacy-converted level, one blank lane when there is
|
||
/// nothing to interpolate.
|
||
#[allow(clippy::type_complexity)]
|
||
fn morph_lane<'e>(
|
||
arena: &'e core_types::arena::Arena,
|
||
flattened: List<Vector>,
|
||
snapshot: List<Graphic<'static>>,
|
||
progression: f64,
|
||
reverse: bool,
|
||
distribution: InterpolationDistribution,
|
||
path: List<Vector>,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
let mut output = morph_core(flattened, snapshot, progression, reverse, distribution, path);
|
||
if output.is_empty() {
|
||
output = List::new_from_element(Vector::default());
|
||
}
|
||
emit_legacy_lane(arena, output, 0)
|
||
}
|
||
|
||
/// 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))]
|
||
fn morph<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
/// The vector objects to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements.
|
||
content: IList<Graphic<'static>>,
|
||
/// 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: Progression,
|
||
/// Swap the direction of the progression between objects or along the control path.
|
||
reverse: 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: 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: IList<Vector>,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
let path_item = path.as_group_item();
|
||
let path = graphic_types::graphic::run_to_list::<Vector>(&path_item).expect("the run holds vector lanes");
|
||
let item = content.as_group_item();
|
||
let flattened = graphic_types::graphic::flatten_vector_rows(graphic_types::graphic::GraphicLevel::Run(&item));
|
||
morph_lane(ctx.arena(), flattened, legacy_graphic_list_of(content), progression, reverse, distribution, path)
|
||
}
|
||
|
||
/// The morph over a plain vector level, as [`morph`]. Registered under the
|
||
/// morph identifier.
|
||
#[node_macro::node(category(""))]
|
||
fn morph_vector<'e>(
|
||
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
|
||
content: IList<Vector>,
|
||
progression: Progression,
|
||
reverse: bool,
|
||
distribution: InterpolationDistribution,
|
||
path: IList<Vector>,
|
||
) -> Result<
|
||
(
|
||
Vector,
|
||
Attr<'e, TransformAttr>,
|
||
Attr<'e, Fill>,
|
||
Attr<'e, StrokeAttr>,
|
||
Attr<'e, BlendModeAttr>,
|
||
Attr<'e, Opacity>,
|
||
Attr<'e, OpacityFill>,
|
||
Attr<'e, ClippingMask>,
|
||
Attr<'e, EditorLayerPath>,
|
||
Attr<'e, EditorMergedLayers>,
|
||
),
|
||
Interrupt,
|
||
> {
|
||
let path_item = path.as_group_item();
|
||
let path = graphic_types::graphic::run_to_list::<Vector>(&path_item).expect("the run holds vector lanes");
|
||
let wrapper = wrap_vector_level(content);
|
||
let flattened = graphic_types::graphic::flatten_vector_rows(graphic_types::graphic::GraphicLevel::Legacy(&wrapper));
|
||
morph_lane(ctx.arena(), flattened, legacy_graphic_list_of(content), progression, reverse, distribution, path)
|
||
}
|
||
|
||
pub use _morph_vector_mod::morph_vector_entries;
|
||
|
||
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, (element, transform): (Vector, Attr<TransformAttr>), #[default(10.)] distance: Length) -> (Vector, Attr<TransformAttr>) {
|
||
(bevel_algorithm(element, *transform, distance), Attr(*transform))
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||
fn close_path(_: impl Ctx, mut source: Vector) -> Vector {
|
||
source.close_subpaths();
|
||
source
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
fn point_inside(_: impl Ctx, source: IList<Vector>, point: DVec2) -> bool {
|
||
(0..source.len()).any(|index| {
|
||
let transform: DAffine2 = source.lane(index).attr::<TransformAttr>();
|
||
source.element_ref(index).check_point_inside_shape(transform, point)
|
||
})
|
||
}
|
||
|
||
// 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))]
|
||
fn list_length<T: Clone + Send + Sync + CacheHash + 'static>(_: impl Ctx, #[implementations(Graphic, Artboard, Vector, Raster<CPU>, Color, Gradient, String)] content: IList<T>) -> f64 {
|
||
content.len() as f64
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(graphene_core::vector))]
|
||
fn count_points(_: impl Ctx, content: IList<Vector>) -> f64 {
|
||
(0..content.len()).map(|index| content.element_ref(index).point_domain.positions().len() as f64).sum()
|
||
}
|
||
|
||
/// 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))]
|
||
fn index_points(
|
||
_: impl Ctx,
|
||
/// The vector element or elements containing the anchor points to be retrieved.
|
||
content: IList<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: f64,
|
||
) -> DVec2 {
|
||
let points_count = (0..content.len()).map(|row| content.element_ref(row).point_domain.positions().len()).sum::<usize>();
|
||
|
||
if points_count == 0 {
|
||
return 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 row in 0..content.len() {
|
||
let vector = content.element_ref(row);
|
||
let row_point_count = vector.point_domain.positions().len();
|
||
if index - accumulated < row_point_count {
|
||
return vector.point_domain.positions()[index - accumulated];
|
||
}
|
||
accumulated += row_point_count;
|
||
}
|
||
|
||
DVec2::ZERO
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
fn path_length(_: impl Ctx, source: IList<Vector>) -> f64 {
|
||
(0..source.len())
|
||
.map(|index| {
|
||
let transform: DAffine2 = source.lane(index).attr::<TransformAttr>();
|
||
|
||
source
|
||
.element_ref(index)
|
||
.stroke_bezpath_iter()
|
||
.map(|mut bezpath| {
|
||
bezpath.apply_affine(Affine::new(transform.to_cols_array()));
|
||
bezpath.perimeter(DEFAULT_ACCURACY)
|
||
})
|
||
.sum::<f64>()
|
||
})
|
||
.sum()
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
// The legacy form reset the footprint before evaluating; the nullification
|
||
// pass now strips it upstream since this node declares no footprint feature.
|
||
fn area(_: impl Ctx, vector: IList<Vector>) -> Result<f64, Interrupt> {
|
||
Ok((0..vector.len())
|
||
.map(|index| {
|
||
let transform: DAffine2 = vector.lane(index).attr::<TransformAttr>();
|
||
let area_scale = transform.matrix2.determinant().abs();
|
||
vector.element_ref(index).stroke_bezpath_iter().map(|subpath| subpath.area() * area_scale).sum::<f64>()
|
||
})
|
||
.sum())
|
||
}
|
||
|
||
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
|
||
// The footprint reset moved to the nullification pass, as in `area`.
|
||
fn centroid(_: impl Ctx, vector: IList<Vector>, centroid_type: CentroidType) -> Result<DVec2, Interrupt> {
|
||
if vector.is_empty() {
|
||
return Ok(DVec2::ZERO);
|
||
}
|
||
|
||
// All subpath centroid positions added together as if they were vectors from the origin.
|
||
let mut centroid = DVec2::ZERO;
|
||
// Cumulative area or length of all subpaths
|
||
let mut sum = 0.;
|
||
|
||
for index in 0..vector.len() {
|
||
let element = vector.element_ref(index);
|
||
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 {
|
||
let transform: DAffine2 = vector.lane(index).attr::<TransformAttr>();
|
||
let subpath_centroid = transform.transform_point2(subpath_centroid);
|
||
|
||
sum += area_or_length;
|
||
centroid += area_or_length * subpath_centroid;
|
||
}
|
||
}
|
||
}
|
||
|
||
if sum > 0. {
|
||
Ok(centroid / sum)
|
||
}
|
||
// Without a summed denominator, return the average of all positions instead
|
||
else {
|
||
let mut count: usize = 0;
|
||
|
||
let summed_positions = (0..vector.len())
|
||
.flat_map(|index| {
|
||
let transform: DAffine2 = vector.lane(index).attr::<TransformAttr>();
|
||
vector
|
||
.element_ref(index)
|
||
.point_domain
|
||
.positions()
|
||
.iter()
|
||
.map(move |&p| transform.transform_point2(p))
|
||
.collect::<Vec<_>>()
|
||
})
|
||
.inspect(|_| count += 1)
|
||
.sum::<DVec2>();
|
||
|
||
if count != 0 { Ok(summed_positions / (count as f64)) } else { Ok(DVec2::ZERO) }
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod test {
|
||
use super::*;
|
||
use core_types::transform::Footprint;
|
||
use graphic_types::graphic::paint_graphics;
|
||
use kurbo::{CubicBez, Ellipse, Point, Rect};
|
||
use vector_types::vector::algorithms::bezpath_algorithms::{TValue, trim_pathseg};
|
||
use vector_types::vector::misc::pathseg_abs_diff_eq;
|
||
|
||
fn vector_node_from_bezpath(bezpath: BezPath) -> List<Vector> {
|
||
List::new_from_element(Vector::from_bezpath(bezpath))
|
||
}
|
||
|
||
fn vector_from_points(points: &[DVec2]) -> 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);
|
||
}
|
||
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.)];
|
||
|
||
#[test]
|
||
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_from_points(&SQUARE_WITH_CENTER), false);
|
||
let (result, _) = super::offset_path(&(), (delaunay, Attr(DAffine2::IDENTITY)), 0.5, StrokeJoin::Miter, 4.);
|
||
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
fn delaunay_disconnected_cells_make_one_region_per_triangle() {
|
||
let vector = super::triangulate(&(), vector_from_points(&SQUARE_WITH_CENTER), false);
|
||
// 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);
|
||
}
|
||
|
||
#[test]
|
||
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_from_points(&SQUARE_WITH_CENTER), false);
|
||
let voronoi = super::voronoi_cells(&(), vector_from_points(&SQUARE_WITH_CENTER), false);
|
||
for sign in subpath_winding_signs(&delaunay) {
|
||
assert_eq!(sign, expected, "Delaunay subpath winding should match the Rectangle/Ellipse generators");
|
||
}
|
||
for sign in subpath_winding_signs(&voronoi) {
|
||
assert_eq!(sign, expected, "Voronoi subpath winding should match the Rectangle/Ellipse generators");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn delaunay_shared_mesh_welds_points_and_shares_edges() {
|
||
let vector = super::triangulate(&(), vector_from_points(&SQUARE_WITH_CENTER), true);
|
||
// 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);
|
||
}
|
||
|
||
#[test]
|
||
fn voronoi_disconnected_cells_make_a_region_per_cell() {
|
||
let vector = super::voronoi_cells(&(), vector_from_points(&SQUARE_WITH_CENTER), false);
|
||
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);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn voronoi_shared_mesh_has_no_regions() {
|
||
let vector = super::voronoi_cells(&(), vector_from_points(&SQUARE_WITH_CENTER), true);
|
||
assert_eq!(vector.region_domain.ids().len(), 0);
|
||
assert!(vector.segment_domain.ids().len() > 0);
|
||
}
|
||
|
||
#[test]
|
||
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 vector = super::voronoi_cells(&(), vector_from_points(&points), false);
|
||
assert_eq!(vector.point_domain.ids().len(), 2);
|
||
assert_eq!(vector.segment_domain.ids().len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
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 vector = super::relax_points(&(), vector_from_points(&points), 2.);
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bounding_box() {
|
||
let bounding_box = super::bounding_box(&(), Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY)));
|
||
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.),]);
|
||
|
||
// The box spans local space, so a lane rotation leaves it unchanged
|
||
let square = Vector::from_bezpath(Rect::new(-1., -1., 1., 1.).to_path(DEFAULT_ACCURACY));
|
||
let bounding_box = super::bounding_box(&(), square);
|
||
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]);
|
||
}
|
||
}
|
||
#[test]
|
||
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::from_bezpath(path), Attr(DAffine2::IDENTITY)),
|
||
PointSpacingType::Separation,
|
||
30.,
|
||
0,
|
||
0.,
|
||
0.,
|
||
false,
|
||
);
|
||
let sample_polyline = &sample_polyline;
|
||
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}");
|
||
}
|
||
}
|
||
#[test]
|
||
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::from_bezpath(path), Attr(DAffine2::IDENTITY)),
|
||
PointSpacingType::Separation,
|
||
18.,
|
||
0,
|
||
45.,
|
||
10.,
|
||
true,
|
||
);
|
||
let sample_polyline = &sample_polyline;
|
||
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}");
|
||
}
|
||
}
|
||
#[test]
|
||
fn poisson() {
|
||
let poisson_points = super::scatter_points(
|
||
&Footprint::default(),
|
||
Vector::from_bezpath(Ellipse::from_rect(Rect::new(-50., -50., 50., 50.)).to_path(DEFAULT_ACCURACY)),
|
||
10. * std::f64::consts::SQRT_2,
|
||
0,
|
||
);
|
||
let poisson_points = &poisson_points;
|
||
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}")
|
||
}
|
||
}
|
||
#[test]
|
||
fn path_length() {
|
||
let frames = core_types::record::test_frames(1 << 16);
|
||
let arena = core_types::arena::Arena::new(1 << 20).unwrap();
|
||
let generations = [];
|
||
let scope = core_types::context::EvalScope::new(None, None, None, &generations, &arena);
|
||
let ctx = core_types::context::ContextImpl::root(&scope);
|
||
|
||
let bezpath = Rect::new(100., 100., 201., 201.).to_path(DEFAULT_ACCURACY);
|
||
let mut row = Vector::default();
|
||
row.append_bezpath(bezpath);
|
||
// Element-only lanes read identity lane transforms; the transform term
|
||
// rides the demo gate.
|
||
let source = core_types::value::LeveledValueSource::new(vec![row; 5]);
|
||
let core_types::record::LevelStatus::Batch(batch, _) = core_types::record::materialize_level(&source, &ctx, &arena, &frames) else {
|
||
panic!("materialize failed")
|
||
};
|
||
let list = unsafe { core_types::node::List::<Vector>::new(batch) };
|
||
|
||
let length = super::path_length(&ctx, list);
|
||
|
||
// 101 (each rectangle edge length) * 4 (rectangle perimeter) * 5 (number of rows)
|
||
assert_eq!(length, 101. * 4. * 5.);
|
||
}
|
||
#[test]
|
||
fn spline() {
|
||
let spline = super::spline(&Footprint::default(), Vector::from_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY)));
|
||
let spline = &spline;
|
||
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.)]);
|
||
}
|
||
#[test]
|
||
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 snapshot = rectangles.into_graphic_list();
|
||
let morphed = super::morph_core(snapshot.clone().into_flattened_list(), snapshot, 0.5, false, InterpolationDistribution::default(), List::default());
|
||
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);
|
||
}
|
||
|
||
#[test]
|
||
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, Some(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, Some(List::new_from_element(Color::BLUE).into_graphic_list()));
|
||
|
||
let mut content = List::new_from_item(item_a);
|
||
content.push(item_b);
|
||
|
||
let snapshot = content.into_graphic_list();
|
||
let morphed = super::morph_core(snapshot.clone().into_flattened_list(), snapshot, 0.5, false, InterpolationDistribution::default(), List::default());
|
||
|
||
let fill = paint_graphics::<Fill, _>(&morphed, 0).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(color)) = fill.element(0) else {
|
||
panic!("Expected a solid color fill, got {:?}", fill.element(0));
|
||
};
|
||
let color = *color;
|
||
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<_>>()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn bevel_rect() {
|
||
let source = Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY);
|
||
let (beveled, _) = super::bevel(&Footprint::default(), (Vector::from_bezpath(source), Attr(DAffine2::IDENTITY)), 2_f64.sqrt() * 10.);
|
||
let beveled = &beveled;
|
||
|
||
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.))));
|
||
}
|
||
|
||
#[test]
|
||
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::from_bezpath(source), Attr(DAffine2::IDENTITY)), 2_f64.sqrt() * 10.);
|
||
let beveled = &beveled;
|
||
|
||
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())));
|
||
}
|
||
|
||
#[test]
|
||
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());
|
||
|
||
// The legacy test set the transform on a list it never passed, so the
|
||
// evaluated lane used the identity; keep that behavior explicit.
|
||
let vector = Vector::from_bezpath(source);
|
||
let (beveled, _) = super::bevel(&(), (vector, Attr(DAffine2::IDENTITY)), 2_f64.sqrt() * 10.);
|
||
let beveled = &beveled;
|
||
|
||
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())));
|
||
}
|
||
|
||
#[test]
|
||
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::from_bezpath(source), Attr(DAffine2::IDENTITY)), 999.);
|
||
let beveled = &beveled;
|
||
|
||
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.))));
|
||
}
|
||
|
||
#[test]
|
||
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, _) = super::bevel(&Footprint::default(), (Vector::from_bezpath(subpath), Attr(DAffine2::IDENTITY)), 5.);
|
||
let beveled = &beveled;
|
||
|
||
assert_eq!(beveled.point_domain.positions().len(), 6);
|
||
assert_eq!(beveled.segment_domain.ids().len(), 5);
|
||
}
|
||
}
|