diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index a0def2ebf3..9c35d4a529 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -23,7 +23,7 @@ use graphene_std::raster::{ use graphene_std::table::{Table, TableRow}; use graphene_std::text::{Font, TextAlign}; use graphene_std::transform::{Footprint, ReferencePoint, Transform}; -use graphene_std::vector::misc::{ArcType, CentroidType, GridType, MergeByDistanceAlgorithm, PointSpacingType, SpiralType}; +use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, MergeByDistanceAlgorithm, PointSpacingType, SpiralType}; use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; pub(crate) fn string_properties(text: &str) -> Vec { @@ -219,6 +219,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 565dbc8649..48f15c0422 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -245,6 +245,7 @@ tagged_value! { GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), MergeByDistanceAlgorithm(vector::misc::MergeByDistanceAlgorithm), + ExtrudeJoiningAlgorithm(vector::misc::ExtrudeJoiningAlgorithm), PointSpacingType(vector::misc::PointSpacingType), SpiralType(vector::misc::SpiralType), #[serde(alias = "LineCap")] diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 9fb82e29a7..c916e394c6 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -134,6 +134,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::GridType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::ArcType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::MergeByDistanceAlgorithm]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::ExtrudeJoiningAlgorithm]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::PointSpacingType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::style::FillType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::style::GradientType]), @@ -220,6 +221,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::GridType]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::ArcType]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::MergeByDistanceAlgorithm]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::ExtrudeJoiningAlgorithm]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::PointSpacingType]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::style::StrokeCap]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::style::StrokeJoin]), diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index e0ab874831..f42d445d51 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -83,6 +83,16 @@ pub enum MergeByDistanceAlgorithm { Topological, } +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)] +#[widget(Radio)] +pub enum ExtrudeJoiningAlgorithm { + All, + #[default] + Extrema, + None, +} + #[repr(C)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)] #[widget(Radio)] diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 3c8d4adf1e..5d2549aa45 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -300,6 +300,10 @@ impl SegmentDomain { self.end_point[segment_index] = new; } + pub fn set_handles(&mut self, segment_index: usize, new: BezierHandles) { + self.handles[segment_index] = new; + } + pub fn handles(&self) -> &[BezierHandles] { &self.handles } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 947b508637..8ec8855e69 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -21,7 +21,7 @@ use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, evaluat 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, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, point_to_dvec2}; +use vector_types::vector::misc::{CentroidType, ExtrudeJoiningAlgorithm, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, point_to_dvec2}; use vector_types::vector::misc::{MergeByDistanceAlgorithm, PointSpacingType, is_linear}; use vector_types::vector::misc::{handles_to_segment, segment_to_handles}; use vector_types::vector::style::{Fill, Gradient, GradientStops, Stroke}; @@ -580,6 +580,210 @@ pub fn merge_by_distance( } } +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 { + 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) { + if point != Found::Both { + 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![0.5]); + assert!(super::find_splits(l1, DVec2::X).collect::>().is_empty()); + + let l2 = kurbo::CubicBez::new((0., 0.), (0., 0.), (100., 0.), (100., 0.)); + assert!(super::find_splits(l2, DVec2::X).collect::>().is_empty()); + + let l3 = kurbo::PathSeg::Line(kurbo::Line::new((0., 0.), (100., 0.))); + assert!(super::find_splits(l3.to_cubic(), DVec2::X).collect::>().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::>(); + assert_eq!(splits.len(), 2); + assert!(splits.iter().all(|&deriv| deriv.y.abs() < 1e-8), "{splits:?}"); + } + + #[test] + fn split_vector() { + let curve = kurbo::PathSeg::Cubic(kurbo::CubicBez::new((0., 0.), (100., -10.), (100., 110.), (0., 100.))); + let mut vector = graphic_types::Vector::from_bezpath(kurbo::BezPath::from_path_segments([curve].into_iter())); + super::split(&mut vector, DVec2::X); + assert_eq!(vector.segment_ids().len(), 3); + assert_eq!(vector.point_domain.ids().len(), 4); + } + } +} + +#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] +async fn extrude(_: impl Ctx, mut source: Table, direction: DVec2, joining_algorithm: ExtrudeJoiningAlgorithm) -> Table { + for TableRowMut { element: source, .. } in source.iter_mut() { + extrude_algorithms::extrude(source, direction, joining_algorithm); + } + source +} + #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] async fn box_warp(_: impl Ctx, content: Table, #[expose] rectangle: Table) -> Table { let Some((target, target_transform)) = rectangle.get(0).map(|rect| (rect.element, rect.transform)) else { @@ -1631,7 +1835,7 @@ async fn morph( let target_segment_len = target_bezpath.segments().count(); let source_segment_len = source_bezpath.segments().count(); - // Insert new segments to align the number of segments in sorce_bezpath and target_bezpath. + // Insert new segments to align the number of segments in source_bezpath and target_bezpath. make_new_segments(&mut source_bezpath, target_segment_len.max(source_segment_len) - source_segment_len); make_new_segments(&mut target_bezpath, source_segment_len.max(target_segment_len) - target_segment_len); @@ -1736,7 +1940,7 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve segments_connected_count[point_index] += 1; } - // Zero out points without exactly two connectors. These are ignored + // Zero out points without exactly two connectors. These are ignored. for count in &mut segments_connected_count { if *count != 2 { *count = 0; @@ -1764,7 +1968,7 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve } } - fn calculate_distance_to_spilt(bezier1: PathSeg, bezier2: PathSeg, bevel_length: f64) -> f64 { + 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(); @@ -1901,7 +2105,7 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve let mut next_bezier = handles_to_segment(next_start, *next_handles, next_end); next_bezier = Affine::new(transform.to_cols_array()) * next_bezier; - let spilt_distance = calculate_distance_to_spilt(bezier, next_bezier, distance); + 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())); @@ -1934,7 +2138,7 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve let valid_length = length > 1e-10; if segments_connected[*end_point] > 0 && valid_length { // Apply the bevel to the end - let distance = spilt_distance.min(original_length.min(next_original_length) / 2.); + 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 { @@ -1953,7 +2157,7 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve let valid_length = next_length > 1e-10; if segments_connected[*next_start_point] > 0 && valid_length { // Apply the bevel to the start - let distance = spilt_distance.min(next_original_length.min(original_length) / 2.); + 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.);