mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-07-19 21:54:59 +00:00
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 <keavon@keavon.com> Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
parent
4587457bfa
commit
5a1c171fc3
18 changed files with 443 additions and 43 deletions
|
@ -2598,6 +2598,29 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
|
|||
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",
|
||||
|
|
|
@ -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<LayoutGroup> {
|
||||
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<LayoutGroup> {
|
||||
let centroid_type = centroid_widget(document_node, node_id, 1);
|
||||
|
||||
vec![centroid_type]
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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<f64>) -> (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.
|
||||
/// <iframe frameBorder="0" width="100%" height="300px" src="https://graphite.rs/libraries/bezier-rs#bezier/length-centroid/solo" title="Length Centroid Demo"></iframe>
|
||||
pub fn length_centroid(&self, tolerance: Option<f64>) -> 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.
|
||||
/// <iframe frameBorder="0" width="100%" height="300px" src="https://graphite.rs/libraries/bezier-rs#bezier/project/solo" title="Project Demo"></iframe>
|
||||
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.);
|
||||
|
|
|
@ -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<f64>) -> 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.));
|
||||
|
|
|
@ -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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
|
|||
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<f64>, 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.
|
||||
/// <iframe frameBorder="0" width="100%" height="300px" src="https://graphite.rs/libraries/bezier-rs#subpath/length-centroid/solo" title="Length Centroid Demo"></iframe>
|
||||
pub fn length_centroid(&self, tolerance: Option<f64>, always_closed: bool) -> Option<DVec2> {
|
||||
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<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
|
|||
})
|
||||
.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<f64>, minimum_separation: Option<f64>) -> Option<DVec2> {
|
||||
pub fn area_centroid_and_area(&self, error: Option<f64>, minimum_separation: Option<f64>) -> Option<(DVec2, f64)> {
|
||||
let all_intersections = self.all_self_intersections(error, minimum_separation);
|
||||
let mut current_sign: f64 = 1.;
|
||||
|
||||
|
@ -112,7 +150,35 @@ impl<ManipulatorGroupId: crate::Identifier> Subpath<ManipulatorGroupId> {
|
|||
})
|
||||
.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.
|
||||
/// <iframe frameBorder="0" width="100%" height="300px" src="https://graphite.rs/libraries/bezier-rs#subpath/area-centroid/solo" title="Area Centroid Demo"></iframe>
|
||||
pub fn area_centroid(&self, error: Option<f64>, minimum_separation: Option<f64>, tolerance: Option<f64>) -> Option<DVec2> {
|
||||
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]
|
||||
|
|
|
@ -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");
|
||||
|
|
11
node-graph/gcore/src/vector/misc.rs
Normal file
11
node-graph/gcore/src/vector/misc.rs
Normal file
|
@ -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,
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
pub mod brush_stroke;
|
||||
pub mod generator_nodes;
|
||||
pub mod misc;
|
||||
|
||||
pub mod style;
|
||||
pub use style::PathStyle;
|
||||
|
|
|
@ -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<SourceFuture: Future<Output = VectorData>, TargetFuture: Future<O
|
|||
result
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AreaNode<VectorData> {
|
||||
vector_data: VectorData,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(AreaNode)]
|
||||
async fn area_node<Fut: Future<Output = VectorData>>(empty: (), vector_data: impl Node<Footprint, Output = Fut>) -> 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<VectorData, CentroidType> {
|
||||
vector_data: VectorData,
|
||||
centroid_type: CentroidType,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(CentroidNode)]
|
||||
async fn centroid_node<Fut: Future<Output = VectorData>>(empty: (), vector_data: impl Node<Footprint, Output = Fut>, 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::<DVec2>() / (positions.len() as f64);
|
||||
return vector_data.transform().transform_point2(centroid);
|
||||
}
|
||||
|
||||
DVec2::ZERO
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
|
|
@ -77,6 +77,7 @@ pub enum TaggedValue {
|
|||
Footprint(graphene_core::transform::Footprint),
|
||||
RenderOutput(RenderOutput),
|
||||
Palette(Vec<Color>),
|
||||
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<Color>),
|
||||
TaggedValue::CentroidType(_) => concrete!(graphene_core::vector::misc::CentroidType),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -366,6 +370,7 @@ impl<'a> TaggedValue {
|
|||
}
|
||||
x if x == TypeId::of::<graphene_core::transform::Footprint>() => Ok(TaggedValue::Footprint(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<Vec<Color>>() => Ok(TaggedValue::Palette(*downcast(input).unwrap())),
|
||||
x if x == TypeId::of::<graphene_core::vector::misc::CentroidType>() => Ok(TaggedValue::CentroidType(*downcast(input).unwrap())),
|
||||
_ => Err(format!("Cannot convert {:?} to TaggedValue", DynAny::type_name(input.as_ref()))),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -578,12 +578,18 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||
async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [String]),
|
||||
async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [Option<Color>]),
|
||||
async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, params: [Vec<Color>]),
|
||||
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<Color>]),
|
||||
async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => Option<Color>]),
|
||||
async_node!(graphene_core::memo::EndLetNode<_, _>, input: WasmEditorApi, output: RenderOutput, fn_params: [Footprint => Vec<Color>]),
|
||||
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<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
|
|||
register_node!(graphene_core::vector::PoissonDiskPoints<_>, 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]),
|
||||
|
|
|
@ -66,6 +66,10 @@ const bezierFeatures = {
|
|||
name: "Length",
|
||||
callback: (bezier: WasmBezierInstance, _: Record<string, number>): string => bezier.length(),
|
||||
},
|
||||
"length-centroid": {
|
||||
name: "Length Centroid",
|
||||
callback: (bezier: WasmBezierInstance, _: Record<string, number>): string => bezier.length_centroid(),
|
||||
},
|
||||
evaluate: {
|
||||
name: "Evaluate",
|
||||
callback: (bezier: WasmBezierInstance, options: Record<string, number>, _: undefined): string => bezier.evaluate(options.t, BEZIER_T_VALUE_VARIANTS[options.TVariant]),
|
||||
|
|
|
@ -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<string, number>, _: undefined): string => subpath.area(options.error, options.minimum_separation),
|
||||
inputOptions: [intersectionErrorOptions, minimumSeparationOptions],
|
||||
},
|
||||
centroid: {
|
||||
name: "Centroid",
|
||||
callback: (subpath: WasmSubpathInstance, options: Record<string, number>, _: undefined): string => subpath.centroid(options.error, options.minimum_separation),
|
||||
"area-centroid": {
|
||||
name: "Area Centroid",
|
||||
callback: (subpath: WasmSubpathInstance, options: Record<string, number>, _: 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<string, number>, _: undefined): string => subpath.poisson_disk_points(options.separation_disk_diameter),
|
||||
inputOptions: [separationDiskDiameter],
|
||||
},
|
||||
evaluate: {
|
||||
name: "Evaluate",
|
||||
callback: (subpath: WasmSubpathInstance, options: Record<string, number>, _: 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<string, number>, _: undefined): string => subpath.poisson_disk_points(options.separation_disk_diameter),
|
||||
inputOptions: [separationDiskDiameter],
|
||||
},
|
||||
inflections: {
|
||||
name: "Inflections",
|
||||
callback: (subpath: WasmSubpathInstance): string => subpath.inflections(),
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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!("<style class=\"poisson\">style.poisson ~ circle {{ fill: {RED}; opacity: 0.25; }}</style>");
|
||||
let content = points
|
||||
.iter()
|
||||
.map(|point| format!("<circle cx=\"{}\" cy=\"{}\" r=\"{r}\" />", point.x, point.y))
|
||||
.collect::<Vec<_>>()
|
||||
.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!("<style class=\"poisson\">style.poisson ~ circle {{ fill: {RED}; opacity: 0.25; }}</style>");
|
||||
let content = points
|
||||
.iter()
|
||||
.map(|point| format!("<circle cx=\"{}\" cy=\"{}\" r=\"{r}\" />", point.x, point.y))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
wrap_svg_tag(format!("{subpath_svg}{points_style}{content}"))
|
||||
}
|
||||
|
||||
pub fn inflections(&self) -> String {
|
||||
let inflections: Vec<f64> = self.0.inflections();
|
||||
|
||||
|
|
|
@ -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#"<path d="M {start_x} {start_y} A {radius} {radius} 0 0 1 {end_x} {end_y} L {} {} L {start_x} {start_y} Z" stroke="none" fill="{fill}" />"#,
|
||||
r#"<path d="M {start_x} {start_y} A {radius} {radius} 0 0 1 {end_x} {end_y} L {} {} L {start_x} {start_y} Z" stroke="none" fill="{fill}" />"#,
|
||||
center.x, center.y
|
||||
);
|
||||
// draw arc with stroke color
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue