From 5a1c171fc33df71a585a1303b969ee01c78d003c Mon Sep 17 00:00:00 2001 From: Elbert Ronnie <103196773+elbertronnie@users.noreply.github.com> Date: Thu, 23 May 2024 01:41:11 +0530 Subject: [PATCH] Add Area and Centroid nodes (#1749) * initial attempt for area node * allow node preview for more types * make AreaNode sync and add CentroidNode * cargo fmt * preview of DVec2 * make the nodes async again * use segment domain instead of region domain * modify the check for linearity * create a limit for area in centroid calculation * cargo fmt * reverse unnecessary changes * add threshold to area calculation too. * handle zero area edge case * add todo comment * implement 1D centroid and use it as fallback * formatting floats to skip last zero * add Centroid Type radio button to Centroid Node * rename docs to use area and perimeter centroid * add web demos for perimeter centroid * add tests for perimeter centroid * add fallback to use average of points * Fix for broken area * missing fixes * Code review and rename Perimeter Centroid to Length Centroid * Use dummy footprint in Area and Centroid nodes * add doc and todo to clarify when `is_linear` fails * use epsilon instead of zero --------- Co-authored-by: 0hypercube <0hypercube@gmail.com> Co-authored-by: Keavon Chambers Co-authored-by: Dennis Kobert --- .../node_graph/document_node_types.rs | 23 ++++ .../document/node_graph/node_properties.rs | 40 ++++++ editor/src/node_graph_executor.rs | 1 + libraries/bezier-rs/src/bezier/core.rs | 13 ++ libraries/bezier-rs/src/bezier/lookup.rs | 88 +++++++++++++ libraries/bezier-rs/src/bezier/solvers.rs | 8 +- libraries/bezier-rs/src/subpath/lookup.rs | 123 ++++++++++++++++-- .../gcore/src/graphic_element/renderer.rs | 1 + node-graph/gcore/src/vector/misc.rs | 11 ++ node-graph/gcore/src/vector/mod.rs | 1 + node-graph/gcore/src/vector/vector_nodes.rs | 70 ++++++++++ node-graph/graph-craft/src/document/value.rs | 5 + .../interpreted-executor/src/node_registry.rs | 8 ++ .../src/features/bezier-features.ts | 4 + .../src/features/subpath-features.ts | 20 +-- .../other/bezier-rs-demos/wasm/src/bezier.rs | 7 + .../other/bezier-rs-demos/wasm/src/subpath.rs | 60 ++++++--- .../bezier-rs-demos/wasm/src/svg_drawing.rs | 3 +- 18 files changed, 443 insertions(+), 43 deletions(-) create mode 100644 node-graph/gcore/src/vector/misc.rs diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_types.rs b/editor/src/messages/portfolio/document/node_graph/document_node_types.rs index 5aba0fa1c..c67f4df82 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_types.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_types.rs @@ -2598,6 +2598,29 @@ fn static_nodes() -> Vec { properties: node_properties::node_no_properties, ..Default::default() }, + DocumentNodeDefinition { + name: "Area", + category: "Vector", + implementation: DocumentNodeImplementation::proto("graphene_core::vector::AreaNode<_>"), + inputs: vec![DocumentInputType::value("Vector Data", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), true)], + outputs: vec![DocumentOutputType::new("Output", FrontendGraphDataType::Number)], + properties: node_properties::node_no_properties, + manual_composition: Some(concrete!(())), + ..Default::default() + }, + DocumentNodeDefinition { + name: "Centroid", + category: "Vector", + implementation: DocumentNodeImplementation::proto("graphene_core::vector::CentroidNode<_, _>"), + inputs: vec![ + DocumentInputType::value("Vector Data", TaggedValue::VectorData(graphene_core::vector::VectorData::empty()), true), + DocumentInputType::value("Centroid Type", TaggedValue::CentroidType(graphene_core::vector::misc::CentroidType::Area), false), + ], + outputs: vec![DocumentOutputType::new("Output", FrontendGraphDataType::Vector)], + properties: node_properties::centroid_properties, + manual_composition: Some(concrete!(())), + ..Default::default() + }, DocumentNodeDefinition { name: "Morph", category: "Vector", 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 6a8033384..416931633 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -14,6 +14,7 @@ use graphene_core::raster::{ SelectiveColorChoice, }; use graphene_core::text::Font; +use graphene_core::vector::misc::CentroidType; use graphene_core::vector::style::{FillType, GradientType, LineCap, LineJoin}; use glam::{DVec2, IVec2, UVec2}; @@ -882,6 +883,39 @@ fn curves_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, na LayoutGroup::Row { widgets } } +fn centroid_widget(document_node: &DocumentNode, node_id: NodeId, index: usize) -> LayoutGroup { + let mut widgets = start_widgets(document_node, node_id, index, "Centroid Type", FrontendGraphDataType::General, true); + if let &NodeInput::Value { + tagged_value: TaggedValue::CentroidType(centroid_type), + exposed: false, + } = &document_node.inputs[index] + { + let entries = vec![ + RadioEntryData::new("area") + .label("Area") + .tooltip("Center of mass for the interior area of the shape") + .on_update(update_value(move |_| TaggedValue::CentroidType(CentroidType::Area), node_id, index)) + .on_commit(commit_value), + RadioEntryData::new("length") + .label("Length") + .tooltip("Center of mass for the perimeter arc length of the shape") + .on_update(update_value(move |_| TaggedValue::CentroidType(CentroidType::Length), node_id, index)) + .on_commit(commit_value), + ]; + + widgets.extend_from_slice(&[ + Separator::new(SeparatorType::Unrelated).widget_holder(), + RadioInput::new(entries) + .selected_index(match centroid_type { + CentroidType::Area => Some(0), + CentroidType::Length => Some(1), + }) + .widget_holder(), + ]); + } + LayoutGroup::Row { widgets } +} + pub fn levels_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { let input_shadows = number_widget(document_node, node_id, 1, "Shadows", NumberInput::default().mode_range().min(0.).max(100.).unit("%"), true); let input_midtones = number_widget(document_node, node_id, 2, "Midtones", NumberInput::default().mode_range().min(0.).max(100.).unit("%"), true); @@ -2438,3 +2472,9 @@ pub fn image_color_palette(document_node: &DocumentNode, node_id: NodeId, _conte vec![LayoutGroup::Row { widgets: size }] } + +pub fn centroid_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { + let centroid_type = centroid_widget(document_node, node_id, 1); + + vec![centroid_type] +} diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index e06dbd3c2..6b85e40bc 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -638,6 +638,7 @@ impl NodeGraphExecutor { TaggedValue::Bool(render_object) => Self::debug_render(render_object, transform, responses), TaggedValue::String(render_object) => Self::debug_render(render_object, transform, responses), TaggedValue::F64(render_object) => Self::debug_render(render_object, transform, responses), + TaggedValue::DVec2(render_object) => Self::debug_render(render_object, transform, responses), TaggedValue::OptionalColor(render_object) => Self::debug_render(render_object, transform, responses), TaggedValue::VectorData(render_object) => Self::debug_render(render_object, transform, responses), TaggedValue::GraphicGroup(render_object) => Self::debug_render(render_object, transform, responses), diff --git a/libraries/bezier-rs/src/bezier/core.rs b/libraries/bezier-rs/src/bezier/core.rs index 12c932582..d2af49b22 100644 --- a/libraries/bezier-rs/src/bezier/core.rs +++ b/libraries/bezier-rs/src/bezier/core.rs @@ -218,6 +218,19 @@ impl Bezier { self.get_points().all(|point| point.abs_diff_eq(start, MAX_ABSOLUTE_DIFFERENCE)) } + + /// Returns true if the Bezier curve is equivalent to a line. + /// + /// **NOTE**: This is different from simply checking if the handle is [`BezierHandles::Linear`]. A [`Quadratic`](BezierHandles::Quadratic) or [`Cubic`](BezierHandles::Cubic) Bezier curve can also be a line if the handles are colinear to the start and end points. Therefore if the handles exceed the start and end point, it will still be considered as a line. + pub fn is_linear(&self) -> bool { + let is_colinear = |a: DVec2, b: DVec2, c: DVec2| -> bool { ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() < MAX_ABSOLUTE_DIFFERENCE }; + + match self.handles { + BezierHandles::Linear => true, + BezierHandles::Quadratic { handle } => is_colinear(self.start, handle, self.end), + BezierHandles::Cubic { handle_start, handle_end } => is_colinear(self.start, handle_start, self.end) && is_colinear(self.start, handle_end, self.end), + } + } } #[cfg(test)] diff --git a/libraries/bezier-rs/src/bezier/lookup.rs b/libraries/bezier-rs/src/bezier/lookup.rs index dffbf7a06..67e34c2b6 100644 --- a/libraries/bezier-rs/src/bezier/lookup.rs +++ b/libraries/bezier-rs/src/bezier/lookup.rs @@ -179,6 +179,75 @@ impl Bezier { } } + /// Return an approximation of the length centroid, together with the length, of the bezier curve. + /// + /// The length centroid is the center of mass for the arc length of the Bezier segment. + /// An infinitely thin wire forming the Bezier segment's shape would balance at this point. + /// + /// - `tolerance` - Tolerance used to approximate the curve. + pub fn length_centroid_and_length(&self, tolerance: Option) -> (DVec2, f64) { + match self.handles { + BezierHandles::Linear => ((self.start + self.end()) / 2., (self.start - self.end).length()), + BezierHandles::Quadratic { handle } => { + // Use Casteljau subdivision, noting that the length is more than the straight line distance from start to end but less than the straight line distance through the handles + fn recurse(a0: DVec2, a1: DVec2, a2: DVec2, tolerance: f64, level: u8) -> (f64, DVec2) { + let lower = a0.distance(a2); + let upper = a0.distance(a1) + a1.distance(a2); + if upper - lower <= 2. * tolerance || level >= 8 { + let length = (lower + upper) / 2.; + return (length, length * (a0 + a1 + a2) / 3.); + } + + let b1 = 0.5 * (a0 + a1); + let c1 = 0.5 * (a1 + a2); + let b2 = 0.5 * (b1 + c1); + + let (length1, centroid_part1) = recurse(a0, b1, b2, 0.5 * tolerance, level + 1); + let (length2, centroid_part2) = recurse(b2, c1, a2, 0.5 * tolerance, level + 1); + (length1 + length2, centroid_part1 + centroid_part2) + } + + let (length, centroid_parts) = recurse(self.start, handle, self.end, tolerance.unwrap_or_default(), 0); + (centroid_parts / length, length) + } + BezierHandles::Cubic { handle_start, handle_end } => { + // Use Casteljau subdivision, noting that the length is more than the straight line distance from start to end but less than the straight line distance through the handles + fn recurse(a0: DVec2, a1: DVec2, a2: DVec2, a3: DVec2, tolerance: f64, level: u8) -> (f64, DVec2) { + let lower = a0.distance(a3); + let upper = a0.distance(a1) + a1.distance(a2) + a2.distance(a3); + if upper - lower <= 2. * tolerance || level >= 8 { + let length = (lower + upper) / 2.; + return (length, length * (a0 + a1 + a2 + a3) / 4.); + } + + let b1 = 0.5 * (a0 + a1); + let t0 = 0.5 * (a1 + a2); + let c1 = 0.5 * (a2 + a3); + let b2 = 0.5 * (b1 + t0); + let c2 = 0.5 * (t0 + c1); + let b3 = 0.5 * (b2 + c2); + + let (length1, centroid_part1) = recurse(a0, b1, b2, b3, 0.5 * tolerance, level + 1); + let (length2, centroid_part2) = recurse(b3, c2, c1, a3, 0.5 * tolerance, level + 1); + (length1 + length2, centroid_part1 + centroid_part2) + } + let (length, centroid_parts) = recurse(self.start, handle_start, handle_end, self.end, tolerance.unwrap_or_default(), 0); + (centroid_parts / length, length) + } + } + } + + /// Return an approximation of the length centroid of the Bezier curve. + /// + /// The length centroid is the center of mass for the arc length of the Bezier segment. + /// An infinitely thin wire with the Bezier segment's shape would balance at this point. + /// + /// - `tolerance` - Tolerance used to approximate the curve. + /// + pub fn length_centroid(&self, tolerance: Option) -> DVec2 { + self.length_centroid_and_length(tolerance).0 + } + /// Returns the parametric `t`-value that corresponds to the closest point on the curve to the provided point. /// pub fn project(&self, point: DVec2) -> f64 { @@ -260,6 +329,25 @@ mod tests { assert!(utils::f64_compare(bezier_cubic.length(None), 199., 1e-2)); } + #[test] + fn test_length_centroid() { + let p1 = DVec2::new(30., 50.); + let p2 = DVec2::new(140., 30.); + let p3 = DVec2::new(160., 170.); + let p4 = DVec2::new(77., 129.); + + let bezier_linear = Bezier::from_linear_dvec2(p1, p2); + assert!(bezier_linear.length_centroid_and_length(None).0.abs_diff_eq((p1 + p2) / 2., MAX_ABSOLUTE_DIFFERENCE)); + + let bezier_quadratic = Bezier::from_quadratic_dvec2(p1, p2, p3); + let expected = DVec2::new(112.81017736920136, 87.98713052477228); + assert!(bezier_quadratic.length_centroid_and_length(None).0.abs_diff_eq(expected, MAX_ABSOLUTE_DIFFERENCE)); + + let bezier_cubic = Bezier::from_cubic_dvec2(p1, p2, p3, p4); + let expected = DVec2::new(95.23597072432115, 88.0645175770206); + assert!(bezier_cubic.length_centroid_and_length(None).0.abs_diff_eq(expected, MAX_ABSOLUTE_DIFFERENCE)); + } + #[test] fn test_project() { let bezier1 = Bezier::from_cubic_coordinates(4., 4., 23., 45., 10., 30., 56., 90.); diff --git a/libraries/bezier-rs/src/bezier/solvers.rs b/libraries/bezier-rs/src/bezier/solvers.rs index fffbd0ee2..266222ec9 100644 --- a/libraries/bezier-rs/src/bezier/solvers.rs +++ b/libraries/bezier-rs/src/bezier/solvers.rs @@ -404,7 +404,13 @@ impl Bezier { /// - `error` - For intersections where the provided bezier is non-linear, `error` defines the threshold for bounding boxes to be considered an intersection point. pub fn unfiltered_intersections(&self, other: &Bezier, error: Option) -> Vec<[f64; 2]> { let error = error.unwrap_or(0.5); - if other.handles == BezierHandles::Linear { + + // TODO: This implementation does not handle the case of line-like bezier curves properly. Two line-like bezier curves which have the same slope + // should not return any intersection points but the current implementation returns many of them. This results in the area of line not being zero. + // Using `is_linear` does prevent it but only in cases where the line-like cubic bezier has it handles at exactly the same position as the start + // and end points. In future, the below algorithm needs to be changed to account for all possible cases. + + if other.is_linear() { // Rotate the bezier and the line by the angle that the line makes with the x axis let line_directional_vector = other.end - other.start; let angle = line_directional_vector.angle_between(DVec2::new(0., 1.)); diff --git a/libraries/bezier-rs/src/subpath/lookup.rs b/libraries/bezier-rs/src/subpath/lookup.rs index 9c09b53fa..c112fe4d6 100644 --- a/libraries/bezier-rs/src/subpath/lookup.rs +++ b/libraries/bezier-rs/src/subpath/lookup.rs @@ -1,5 +1,5 @@ use super::*; -use crate::consts::{DEFAULT_EUCLIDEAN_ERROR_BOUND, DEFAULT_LUT_STEP_SIZE}; +use crate::consts::{DEFAULT_EUCLIDEAN_ERROR_BOUND, DEFAULT_LUT_STEP_SIZE, MAX_ABSOLUTE_DIFFERENCE}; use crate::utils::{SubpathTValue, TValue, TValueType}; use glam::DVec2; @@ -30,11 +30,41 @@ impl Subpath { self.iter().map(|bezier| bezier.length(tolerance)).sum() } + /// Return the approximation of the length centroid, together with the length, of the `Subpath`. + /// + /// The length centroid is the center of mass for the arc length of the solid shape's perimeter. + /// An infinitely thin wire forming the subpath's closed shape would balance at this point. + /// + /// It will return `None` if no manipulator is present. + /// - `tolerance` - Tolerance used to approximate the curve. + /// - `always_closed` - consider the subpath as closed always. + pub fn length_centroid_and_length(&self, tolerance: Option, always_closed: bool) -> Option<(DVec2, f64)> { + if always_closed { self.iter_closed() } else { self.iter() } + .map(|bezier| bezier.length_centroid_and_length(tolerance)) + .map(|(centroid, length)| (centroid * length, length)) + .reduce(|(centroid_part1, length1), (centroid_part2, length2)| (centroid_part1 + centroid_part2, length1 + length2)) + .map(|(centroid_part, length)| (centroid_part / length, length)) + } + + /// Return the approximation of the length centroid of the `Subpath`. + /// + /// The length centroid is the center of mass for the arc length of the solid shape's perimeter. + /// An infinitely thin wire forming the subpath's closed shape would balance at this point. + /// + /// It will return `None` if no manipulator is present. + /// - `tolerance` - Tolerance used to approximate the curve. + /// - `always_closed` - consider the subpath as closed always. + /// + pub fn length_centroid(&self, tolerance: Option, always_closed: bool) -> Option { + self.length_centroid_and_length(tolerance, always_closed).map(|(centroid, _)| centroid) + } + /// Return the area enclosed by the `Subpath` always considering it as a closed subpath. It will always give a positive value. /// + /// If the area is less than `error`, it will return zero. /// Because the calculation of area for self-intersecting path requires finding the intersections, the following parameters are used: /// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point. - /// - `minimum_separation`: the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. + /// - `minimum_separation` - the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two /// /// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out. @@ -62,19 +92,27 @@ impl Subpath { }) .sum(); + if area.abs() < error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE) { + return 0.; + } + area.abs() } - /// Return the centroid of the `Subpath` always considering it as a closed subpath. - /// It will return `None` if no manipulator is present. + /// Return the area centroid, together with the area, of the `Subpath` always considering it as a closed subpath. The area will always be a positive value. /// - /// Because the calculation of area for self-intersecting path requires finding the intersections, the following parameters are used: + /// The area centroid is the center of mass for the area of a solid shape's interior. + /// An infinitely flat material forming the subpath's closed shape would balance at this point. + /// + /// It will return `None` if no manipulator is present. If the area is less than `error`, it will return `Some((DVec2::NAN, 0.))`. + /// + /// Because the calculation of area and centroid for self-intersecting path requires finding the intersections, the following parameters are used: /// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point. - /// - `minimum_separation`: the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. - /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two + /// - `minimum_separation` - the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. + /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two. /// /// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out. - pub fn centroid(&self, error: Option, minimum_separation: Option) -> Option { + pub fn area_centroid_and_area(&self, error: Option, minimum_separation: Option) -> Option<(DVec2, f64)> { let all_intersections = self.all_self_intersections(error, minimum_separation); let mut current_sign: f64 = 1.; @@ -112,7 +150,35 @@ impl Subpath { }) .reduce(|(x1, y1, area1), (x2, y2, area2)| (x1 + x2, y1 + y2, area1 + area2))?; - Some(DVec2::new(x_sum / area, y_sum / area)) + if area.abs() < error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE) { + return Some((DVec2::NAN, 0.)); + } + + Some((DVec2::new(x_sum / area, y_sum / area), area.abs())) + } + + /// Attempts to return the area centroid of the `Subpath` always considering it as a closed subpath. Falls back to length centroid if the area is zero. + /// + /// The area centroid is the center of mass for the area of a solid shape's interior. + /// An infinitely flat material forming the subpath's closed shape would balance at this point. + /// + /// It will return `None` if no manipulator is present. + /// Because the calculation of centroid for self-intersecting path requires finding the intersections, the following parameters are used: + /// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point. + /// - `minimum_separation` - the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order. + /// - `tolerance` - Tolerance used to approximate the curve if it falls back to length centroid. + /// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two + /// + /// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out. + /// + pub fn area_centroid(&self, error: Option, minimum_separation: Option, tolerance: Option) -> Option { + let (centroid, area) = self.area_centroid_and_area(error, minimum_separation)?; + + if area != 0. { + Some(centroid) + } else { + self.length_centroid_and_length(tolerance, true).map(|(centroid, _)| centroid) + } } /// Converts from a subpath (composed of multiple segments) to a point along a certain segment represented. @@ -293,6 +359,39 @@ mod tests { assert_eq!(subpath.length(None), linear_bezier.length(None) + quadratic_bezier.length(None) + cubic_bezier.length(None)); } + #[test] + fn length_centroid() { + let start = DVec2::new(0., 0.); + let end = DVec2::new(1., 1.); + let handle = DVec2::new(0., 1.); + + let mut subpath = Subpath::new( + vec![ + ManipulatorGroup { + anchor: start, + in_handle: None, + out_handle: Some(handle), + id: EmptyId, + }, + ManipulatorGroup { + anchor: end, + in_handle: None, + out_handle: None, + id: EmptyId, + }, + ], + false, + ); + + let expected_centroid = DVec2::new(0.4153039799983826, 0.5846960200016174); + let epsilon = 0.00001; + + assert!(subpath.length_centroid_and_length(None, true).unwrap().0.abs_diff_eq(expected_centroid, epsilon)); + + subpath.closed = true; + assert!(subpath.length_centroid_and_length(None, true).unwrap().0.abs_diff_eq(expected_centroid, epsilon)); + } + #[test] fn area() { let start = DVec2::new(0., 0.); @@ -327,7 +426,7 @@ mod tests { } #[test] - fn centroid() { + fn area_centroid() { let start = DVec2::new(0., 0.); let end = DVec2::new(1., 1.); let handle = DVec2::new(0., 1.); @@ -353,10 +452,10 @@ mod tests { let expected_centroid = DVec2::new(0.4, 0.6); let epsilon = 0.00001; - assert!(subpath.centroid(Some(0.001), Some(0.001)).unwrap().abs_diff_eq(expected_centroid, epsilon)); + assert!(subpath.area_centroid(Some(0.001), Some(0.001), None).unwrap().abs_diff_eq(expected_centroid, epsilon)); subpath.closed = true; - assert!(subpath.centroid(Some(0.001), Some(0.001)).unwrap().abs_diff_eq(expected_centroid, epsilon)); + assert!(subpath.area_centroid(Some(0.001), Some(0.001), None).unwrap().abs_diff_eq(expected_centroid, epsilon)); } #[test] diff --git a/node-graph/gcore/src/graphic_element/renderer.rs b/node-graph/gcore/src/graphic_element/renderer.rs index 7d2127dfa..ef54e9a56 100644 --- a/node-graph/gcore/src/graphic_element/renderer.rs +++ b/node-graph/gcore/src/graphic_element/renderer.rs @@ -595,6 +595,7 @@ impl Primitive for String {} impl Primitive for bool {} impl Primitive for f32 {} impl Primitive for f64 {} +impl Primitive for DVec2 {} fn text_attributes(attributes: &mut SvgRenderAttrs) { attributes.push("fill", "white"); diff --git a/node-graph/gcore/src/vector/misc.rs b/node-graph/gcore/src/vector/misc.rs new file mode 100644 index 000000000..da48ee101 --- /dev/null +++ b/node-graph/gcore/src/vector/misc.rs @@ -0,0 +1,11 @@ +use dyn_any::{DynAny, StaticType}; + +/// Represents different ways of calculating the centroid. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type)] +pub enum CentroidType { + /// The center of mass for the area of a solid shape's interior, as if made out of an infinitely flat material. + #[default] + Area, + /// The center of mass for the arc length of a curved shape's perimeter, as if made out of an infinitely thin wire. + Length, +} diff --git a/node-graph/gcore/src/vector/mod.rs b/node-graph/gcore/src/vector/mod.rs index 6b70aff28..e729eede5 100644 --- a/node-graph/gcore/src/vector/mod.rs +++ b/node-graph/gcore/src/vector/mod.rs @@ -1,5 +1,6 @@ pub mod brush_stroke; pub mod generator_nodes; +pub mod misc; pub mod style; pub use style::PathStyle; diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index f8d443bf1..050165022 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -1,3 +1,4 @@ +use super::misc::CentroidType; use super::style::{Fill, FillType, Gradient, GradientType, Stroke}; use super::{PointId, SegmentId, StrokeId, VectorData}; use crate::renderer::GraphicElementRendered; @@ -530,6 +531,75 @@ async fn morph, TargetFuture: Future { + vector_data: VectorData, +} + +#[node_macro::node_fn(AreaNode)] +async fn area_node>(empty: (), vector_data: impl Node) -> f64 { + let vector_data = self.vector_data.eval(Footprint::default()).await; + + let mut area = 0.; + let scale = vector_data.transform.decompose_scale(); + for subpath in vector_data.stroke_bezier_paths() { + area += subpath.area(Some(1e-3), Some(1e-3)); + } + area * scale[0] * scale[1] +} + +#[derive(Debug, Clone, Copy)] +pub struct CentroidNode { + vector_data: VectorData, + centroid_type: CentroidType, +} + +#[node_macro::node_fn(CentroidNode)] +async fn centroid_node>(empty: (), vector_data: impl Node, centroid_type: CentroidType) -> DVec2 { + let vector_data = self.vector_data.eval(Footprint::default()).await; + + if centroid_type == CentroidType::Area { + let mut area = 0.; + let mut centroid = DVec2::ZERO; + for subpath in vector_data.stroke_bezier_paths() { + if let Some((subpath_centroid, subpath_area)) = subpath.area_centroid_and_area(Some(1e-3), Some(1e-3)) { + if subpath_area == 0. { + continue; + } + area += subpath_area; + centroid += subpath_area * subpath_centroid; + } + } + + if area != 0. { + centroid /= area; + return vector_data.transform().transform_point2(centroid); + } + } + + let mut length = 0.; + let mut centroid = DVec2::ZERO; + for subpath in vector_data.stroke_bezier_paths() { + if let Some((subpath_centroid, subpath_length)) = subpath.length_centroid_and_length(None, true) { + length += subpath_length; + centroid += subpath_length * subpath_centroid; + } + } + + if length != 0. { + centroid /= length; + return vector_data.transform().transform_point2(centroid); + } + + let positions = vector_data.point_domain.positions(); + if !positions.is_empty() { + let centroid = positions.iter().sum::() / (positions.len() as f64); + return vector_data.transform().transform_point2(centroid); + } + + DVec2::ZERO +} + #[cfg(test)] mod test { use super::*; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 13dfb35a7..66111d61c 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -77,6 +77,7 @@ pub enum TaggedValue { Footprint(graphene_core::transform::Footprint), RenderOutput(RenderOutput), Palette(Vec), + CentroidType(graphene_core::vector::misc::CentroidType), } #[allow(clippy::derived_hash_with_manual_eq)] @@ -156,6 +157,7 @@ impl Hash for TaggedValue { Self::Footprint(x) => x.hash(state), Self::RenderOutput(x) => x.hash(state), Self::Palette(x) => x.hash(state), + Self::CentroidType(x) => x.hash(state), } } } @@ -222,6 +224,7 @@ impl<'a> TaggedValue { TaggedValue::Footprint(x) => Box::new(x), TaggedValue::RenderOutput(x) => Box::new(x), TaggedValue::Palette(x) => Box::new(x), + TaggedValue::CentroidType(x) => Box::new(x), } } @@ -299,6 +302,7 @@ impl<'a> TaggedValue { TaggedValue::Footprint(_) => concrete!(graphene_core::transform::Footprint), TaggedValue::RenderOutput(_) => concrete!(RenderOutput), TaggedValue::Palette(_) => concrete!(Vec), + TaggedValue::CentroidType(_) => concrete!(graphene_core::vector::misc::CentroidType), } } @@ -366,6 +370,7 @@ impl<'a> TaggedValue { } x if x == TypeId::of::() => Ok(TaggedValue::Footprint(*downcast(input).unwrap())), x if x == TypeId::of::>() => Ok(TaggedValue::Palette(*downcast(input).unwrap())), + x if x == TypeId::of::() => Ok(TaggedValue::CentroidType(*downcast(input).unwrap())), _ => Err(format!("Cannot convert {:?} to TaggedValue", DynAny::type_name(input.as_ref()))), } } diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index e30fcb2c3..424e7198e 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -578,12 +578,18 @@ fn node_registry() -> HashMap, input: WasmEditorApi, output: RenderOutput, params: [String]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [Option]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [Vec]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [DVec2]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => VectorData]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => ImageFrame]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => Option]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => Vec]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => GraphicGroup]), async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => Artboard]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => f32]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => f64]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => bool]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => String]), + async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => DVec2]), async_node!( graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, @@ -774,6 +780,8 @@ fn node_registry() -> HashMap, input: VectorData, params: [f64]), register_node!(graphene_core::vector::LengthsOfSegmentsOfSubpaths, input: VectorData, params: []), register_node!(graphene_core::vector::SplinesFromPointsNode, input: VectorData, params: []), + async_node!(graphene_core::vector::AreaNode<_>, input: (), output: f64, fn_params: [Footprint => VectorData]), + async_node!(graphene_core::vector::CentroidNode<_, _>, input: (), output: DVec2, fn_params: [Footprint => VectorData, () => graphene_core::vector::misc::CentroidType]), async_node!(graphene_core::vector::MorphNode<_, _, _, _>, input: Footprint, output: VectorData, fn_params: [Footprint => VectorData, Footprint => VectorData, () => u32, () => f64]), register_node!(graphene_core::vector::generator_nodes::CircleGenerator<_>, input: (), params: [f64]), register_node!(graphene_core::vector::generator_nodes::EllipseGenerator<_, _>, input: (), params: [f64, f64]), diff --git a/website/other/bezier-rs-demos/src/features/bezier-features.ts b/website/other/bezier-rs-demos/src/features/bezier-features.ts index 72b63e117..98d170984 100644 --- a/website/other/bezier-rs-demos/src/features/bezier-features.ts +++ b/website/other/bezier-rs-demos/src/features/bezier-features.ts @@ -66,6 +66,10 @@ const bezierFeatures = { name: "Length", callback: (bezier: WasmBezierInstance, _: Record): string => bezier.length(), }, + "length-centroid": { + name: "Length Centroid", + callback: (bezier: WasmBezierInstance, _: Record): string => bezier.length_centroid(), + }, evaluate: { name: "Evaluate", callback: (bezier: WasmBezierInstance, options: Record, _: undefined): string => bezier.evaluate(options.t, BEZIER_T_VALUE_VARIANTS[options.TVariant]), diff --git a/website/other/bezier-rs-demos/src/features/subpath-features.ts b/website/other/bezier-rs-demos/src/features/subpath-features.ts index 004c31a75..b67d62f1c 100644 --- a/website/other/bezier-rs-demos/src/features/subpath-features.ts +++ b/website/other/bezier-rs-demos/src/features/subpath-features.ts @@ -16,16 +16,25 @@ const subpathFeatures = { name: "Length", callback: (subpath: WasmSubpathInstance): string => subpath.length(), }, + "length-centroid": { + name: "Length Centroid", + callback: (subpath: WasmSubpathInstance): string => subpath.length_centroid(), + }, area: { name: "Area", callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.area(options.error, options.minimum_separation), inputOptions: [intersectionErrorOptions, minimumSeparationOptions], }, - centroid: { - name: "Centroid", - callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.centroid(options.error, options.minimum_separation), + "area-centroid": { + name: "Area Centroid", + callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.area_centroid(options.error, options.minimum_separation), inputOptions: [intersectionErrorOptions, minimumSeparationOptions], }, + "poisson-disk-points": { + name: "Poisson-Disk Points", + callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.poisson_disk_points(options.separation_disk_diameter), + inputOptions: [separationDiskDiameter], + }, evaluate: { name: "Evaluate", callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.evaluate(options.t, SUBPATH_T_VALUE_VARIANTS[options.TVariant]), @@ -69,11 +78,6 @@ const subpathFeatures = { name: "Bounding Box", callback: (subpath: WasmSubpathInstance): string => subpath.bounding_box(), }, - "poisson-disk-points": { - name: "Poisson-Disk Points", - callback: (subpath: WasmSubpathInstance, options: Record, _: undefined): string => subpath.poisson_disk_points(options.separation_disk_diameter), - inputOptions: [separationDiskDiameter], - }, inflections: { name: "Inflections", callback: (subpath: WasmSubpathInstance): string => subpath.inflections(), diff --git a/website/other/bezier-rs-demos/wasm/src/bezier.rs b/website/other/bezier-rs-demos/wasm/src/bezier.rs index 094c00f81..1264c21e3 100644 --- a/website/other/bezier-rs-demos/wasm/src/bezier.rs +++ b/website/other/bezier-rs-demos/wasm/src/bezier.rs @@ -148,6 +148,13 @@ impl WasmBezier { wrap_svg_tag(format!("{bezier}{}", draw_text(format!("Length: {:.2}", self.0.length(None)), TEXT_OFFSET_X, TEXT_OFFSET_Y, BLACK))) } + pub fn length_centroid(&self) -> String { + let bezier = self.get_bezier_path(); + let centroid = self.0.length_centroid(None); + let point_text = draw_circle(centroid, 4., RED, 1.5, WHITE); + wrap_svg_tag(format!("{bezier}{}", point_text)) + } + pub fn evaluate(&self, raw_t: f64, t_variant: String) -> String { let bezier = self.get_bezier_path(); let t = parse_t_variant(&t_variant, raw_t); diff --git a/website/other/bezier-rs-demos/wasm/src/subpath.rs b/website/other/bezier-rs-demos/wasm/src/subpath.rs index 9b5685af7..ecf62d7cf 100644 --- a/website/other/bezier-rs-demos/wasm/src/subpath.rs +++ b/website/other/bezier-rs-demos/wasm/src/subpath.rs @@ -76,6 +76,18 @@ impl WasmSubpath { subpath_svg } + fn to_filled_svg(&self) -> String { + let mut subpath_svg = String::new(); + self.0.to_svg( + &mut subpath_svg, + CURVE_FILLED_ATTRIBUTES.to_string(), + ANCHOR_ATTRIBUTES.to_string(), + HANDLE_ATTRIBUTES.to_string(), + HANDLE_LINE_ATTRIBUTES.to_string(), + ); + subpath_svg + } + pub fn insert(&self, t: f64, t_variant: String) -> String { let mut subpath = self.0.clone(); let t = parse_t_variant(&t_variant, t); @@ -92,14 +104,35 @@ impl WasmSubpath { wrap_svg_tag(format!("{}{}", self.to_default_svg(), length_text)) } - pub fn area(&self, error: f64, minimum_separation: f64) -> String { - let area_text = draw_text(format!("Area: {}", self.0.area(Some(error), Some(minimum_separation))), 5., 193., BLACK); - wrap_svg_tag(format!("{}{}", self.to_default_svg(), area_text)) + pub fn length_centroid(&self) -> String { + let centroid = self.0.length_centroid(None, true).unwrap(); + let point_text = draw_circle(centroid, 4., RED, 1.5, WHITE); + wrap_svg_tag(format!("{}{}", self.to_default_svg(), point_text)) } - pub fn centroid(&self, error: f64, minimum_separation: f64) -> String { - let point_text = draw_circle(self.0.centroid(Some(error), Some(minimum_separation)).unwrap(), 4., RED, 1.5, WHITE); - wrap_svg_tag(format!("{}{}", self.to_default_svg(), point_text)) + pub fn area(&self, error: f64, minimum_separation: f64) -> String { + let area_text = draw_text(format!("Area: {}", self.0.area(Some(error), Some(minimum_separation))), 5., 193., BLACK); + wrap_svg_tag(format!("{}{}", self.to_filled_svg(), area_text)) + } + + pub fn area_centroid(&self, error: f64, minimum_separation: f64) -> String { + let point_text = draw_circle(self.0.area_centroid(Some(error), Some(minimum_separation), None).unwrap(), 4., RED, 1.5, WHITE); + wrap_svg_tag(format!("{}{}", self.to_filled_svg(), point_text)) + } + + pub fn poisson_disk_points(&self, separation_disk_diameter: f64) -> String { + let r = separation_disk_diameter / 2.; + + let subpath_svg = self.to_default_svg(); + let points = self.0.poisson_disk_points(separation_disk_diameter, Math::random); + + let points_style = format!(""); + let content = points + .iter() + .map(|point| format!("", point.x, point.y)) + .collect::>() + .join(""); + wrap_svg_tag(format!("{subpath_svg}{points_style}{content}")) } pub fn evaluate(&self, t: f64, t_variant: String) -> String { @@ -193,21 +226,6 @@ impl WasmSubpath { } } - pub fn poisson_disk_points(&self, separation_disk_diameter: f64) -> String { - let r = separation_disk_diameter / 2.; - - let subpath_svg = self.to_default_svg(); - let points = self.0.poisson_disk_points(separation_disk_diameter, Math::random); - - let points_style = format!(""); - let content = points - .iter() - .map(|point| format!("", point.x, point.y)) - .collect::>() - .join(""); - wrap_svg_tag(format!("{subpath_svg}{points_style}{content}")) - } - pub fn inflections(&self) -> String { let inflections: Vec = self.0.inflections(); diff --git a/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs b/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs index 3b07fa39c..a54c8403b 100644 --- a/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs +++ b/website/other/bezier-rs-demos/wasm/src/svg_drawing.rs @@ -16,6 +16,7 @@ pub const NONE: &str = "none"; // Default attributes pub const CURVE_ATTRIBUTES: &str = "stroke=\"black\" stroke-width=\"2\" fill=\"none\""; +pub const CURVE_FILLED_ATTRIBUTES: &str = "stroke=\"black\" stroke-width=\"2\" fill=\"lightgray\""; pub const HANDLE_LINE_ATTRIBUTES: &str = "stroke=\"gray\" stroke-width=\"1\" fill=\"none\""; pub const ANCHOR_ATTRIBUTES: &str = "r=\"4\" stroke=\"black\" stroke-width=\"2\" fill=\"white\""; pub const HANDLE_ATTRIBUTES: &str = "r=\"3\" stroke=\"gray\" stroke-width=\"1.5\" fill=\"white\""; @@ -59,7 +60,7 @@ pub fn draw_sector(center: DVec2, radius: f64, start_angle: f64, end_angle: f64, let [end_x, end_y] = polar_to_cartesian(center.x, center.y, radius, end_angle); // draw sector with fill color let sector_svg = format!( - r#""#, + r#""#, center.x, center.y ); // draw arc with stroke color