diff --git a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs index 67f5a0e6cb..ca9791d785 100644 --- a/editor/src/messages/tool/common_functionality/graph_modification_utils.rs +++ b/editor/src/messages/tool/common_functionality/graph_modification_utils.rs @@ -587,6 +587,10 @@ pub fn get_text_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkIn NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::text::text::IDENTIFIER)) } +pub fn get_heart_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { + NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::heart::IDENTIFIER)) +} + pub fn get_grid_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option { NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::grid::IDENTIFIER)) } diff --git a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs new file mode 100644 index 0000000000..2b33e374c9 --- /dev/null +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -0,0 +1,80 @@ +use crate::messages::message::Message; +use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; +use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type; +use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; +use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate}; +use crate::messages::prelude::{DocumentMessageHandler, InputPreprocessorMessageHandler}; +use crate::messages::tool::common_functionality::graph_modification_utils; +use crate::messages::tool::common_functionality::resize::viewport_zoom; +use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeToolModifierKey; +use crate::messages::tool::tool_messages::shape_tool::ShapeToolData; +use crate::messages::tool::tool_messages::tool_prelude::*; +use glam::DAffine2; +use graph_craft::document::NodeInput; +use graph_craft::document::value::TaggedValue; +use std::collections::VecDeque; + +/// The heart's size is adjusted via a registry-driven radius gizmo (see the [gizmo registry]), while its +/// parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. +/// +/// [gizmo registry]: crate::messages::tool::common_functionality::gizmos::gizmo_registry +#[derive(Default)] +pub struct Heart; + +impl Heart { + pub fn create_node() -> NodeTemplate { + let node_type = resolve_proto_node_type(graphene_std::vector::generator_nodes::heart::IDENTIFIER).expect("Heart node can't be found"); + node_type.node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(0.), false))]) + } + + pub fn update_shape( + document: &DocumentMessageHandler, + ipp: &InputPreprocessorMessageHandler, + viewport: &ViewportMessageHandler, + layer: LayerNodeIdentifier, + shape_tool_data: &mut ShapeToolData, + modifier: ShapeToolModifierKey, + responses: &mut VecDeque, + ) { + let [center, lock_ratio, _] = modifier; + + if let Some([start, end]) = shape_tool_data.data.calculate_points(document, ipp, viewport, center, lock_ratio) { + let Some(node_id) = graph_modification_utils::get_heart_id(layer, &document.network_interface) else { + return; + }; + + // In document units, as every other generator does: without this the heart is drawn at the + // wrong size whenever the canvas is not at 100% zoom. + let dimensions = ((start - end) / viewport_zoom(document)).abs(); + + // A drag that is exactly horizontal, exactly vertical, or has not moved leaves one dimension at + // zero. Dividing by it would write an infinite or NaN scale into the layer transform, so the + // degenerate frame is skipped and the last good size stands. + if dimensions.x == 0. || dimensions.y == 0. { + return; + } + + let mut scale = DVec2::ONE; + let radius: f64; + if dimensions.x > dimensions.y { + scale.x = dimensions.x / dimensions.y; + radius = dimensions.y / 2.; + } else { + scale.y = dimensions.y / dimensions.x; + radius = dimensions.x / 2.; + } + + responses.add(NodeGraphMessage::SetInput { + input_connector: InputConnector::node(node_id, graphene_std::vector::generator_nodes::heart::RadiusInput), + input: NodeInput::value(TaggedValue::F64(radius), false), + }); + + responses.add(GraphOperationMessage::TransformSet { + layer, + transform: DAffine2::from_scale_angle_translation(scale, 0., (start + end) / 2.), + transform_in: TransformIn::Viewport, + skip_rerender: false, + }); + } + } +} diff --git a/editor/src/messages/tool/common_functionality/shapes/mod.rs b/editor/src/messages/tool/common_functionality/shapes/mod.rs index 4d74b15ba5..74036abf9f 100644 --- a/editor/src/messages/tool/common_functionality/shapes/mod.rs +++ b/editor/src/messages/tool/common_functionality/shapes/mod.rs @@ -3,6 +3,7 @@ pub mod arrow_shape; pub mod circle_shape; pub mod ellipse_shape; pub mod grid_shape; +pub mod heart_shape; pub mod line_shape; pub mod polygon_shape; pub mod rectangle_shape; diff --git a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs index 0558adcab9..ff61c0d29a 100644 --- a/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs +++ b/editor/src/messages/tool/common_functionality/shapes/shape_utility.rs @@ -35,6 +35,7 @@ pub enum ShapeType { Spiral, Grid, Arrow, + Heart, Line, // KEEP THIS AT THE END Rectangle, // KEEP THIS AT THE END Ellipse, // KEEP THIS AT THE END @@ -50,6 +51,7 @@ impl ShapeType { ShapeType::Spiral, ShapeType::Grid, ShapeType::Arrow, + ShapeType::Heart, ShapeType::Line, // KEEP THIS AT THE END ShapeType::Rectangle, // KEEP THIS AT THE END ShapeType::Ellipse, // KEEP THIS AT THE END @@ -58,7 +60,10 @@ impl ShapeType { /// True if this shape mode's fill checkbox is ticked by default when nothing is selected. /// Spiral/Grid/Line are open paths and default to fill-off, the closed shapes default to fill-on. pub fn defaults_to_fill(&self) -> bool { - matches!(self, Self::Polygon | Self::Star | Self::Circle | Self::Arc | Self::Rectangle | Self::Ellipse | Self::Arrow) + matches!( + self, + Self::Polygon | Self::Star | Self::Circle | Self::Arc | Self::Rectangle | Self::Ellipse | Self::Arrow | Self::Heart + ) } pub fn name(&self) -> String { @@ -70,6 +75,7 @@ impl ShapeType { Self::Spiral => "Spiral", Self::Grid => "Grid", Self::Arrow => "Arrow", + Self::Heart => "Heart", Self::Line => "Line", // KEEP THIS AT THE END Self::Rectangle => "Rectangle", // KEEP THIS AT THE END Self::Ellipse => "Ellipse", // KEEP THIS AT THE END diff --git a/editor/src/messages/tool/tool_messages/shape_tool.rs b/editor/src/messages/tool/tool_messages/shape_tool.rs index eda939a304..b4e268742a 100644 --- a/editor/src/messages/tool/tool_messages/shape_tool.rs +++ b/editor/src/messages/tool/tool_messages/shape_tool.rs @@ -16,6 +16,7 @@ use crate::messages::tool::common_functionality::shapes::arc_shape::Arc; use crate::messages::tool::common_functionality::shapes::arrow_shape::Arrow; use crate::messages::tool::common_functionality::shapes::circle_shape::Circle; use crate::messages::tool::common_functionality::shapes::grid_shape::Grid; +use crate::messages::tool::common_functionality::shapes::heart_shape::Heart; use crate::messages::tool::common_functionality::shapes::line_shape::LineToolData; use crate::messages::tool::common_functionality::shapes::polygon_shape::Polygon; use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeToolModifierKey, ShapeType, anchor_overlays, clicked_on_shape_endpoints, transform_cage_overlays}; @@ -212,6 +213,12 @@ fn create_shape_option_widget(shape_type: ShapeType) -> WidgetInstance { } .into() }), + MenuListEntry::new("Heart").label("Heart").on_commit(move |_| { + ShapeToolMessage::UpdateOptions { + options: ShapeOptionsUpdate::ShapeType(ShapeType::Heart), + } + .into() + }), ]]; DropdownInput::new(entries).selected_index(Some(shape_type as u32)).widget_instance() } @@ -325,6 +332,7 @@ fn sync_shape_options_from_selection(options: &mut ShapeToolOptions, tool_data: (spiral::IDENTIFIER, ShapeType::Spiral), (grid::IDENTIFIER, ShapeType::Grid), (arrow::IDENTIFIER, ShapeType::Arrow), + (heart::IDENTIFIER, ShapeType::Heart), ] .into_iter() .find_map(|(id, shape)| layer_view.upstream_node_id_from_name(&proto(id)).map(|_| shape)) else { @@ -407,7 +415,7 @@ fn sync_shape_options_from_selection(options: &mut ShapeToolOptions, tool_data: changed = true; } } - ShapeType::Ellipse | ShapeType::Rectangle | ShapeType::Line | ShapeType::Circle => {} + ShapeType::Ellipse | ShapeType::Rectangle | ShapeType::Line | ShapeType::Circle | ShapeType::Heart => {} } changed @@ -1088,7 +1096,7 @@ impl Fsm for ShapeToolFsmState { }; match tool_data.current_shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => { + ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse | ShapeType::Heart => { tool_data.data.start(document, input, viewport); } ShapeType::Arrow | ShapeType::Line => { @@ -1111,6 +1119,7 @@ impl Fsm for ShapeToolFsmState { ShapeType::Spiral => Spiral::create_node(tool_options.spiral_type, tool_options.turns), ShapeType::Grid => Grid::create_node(tool_options.grid_type), ShapeType::Arrow => Arrow::create_node(tool_options.arrow_shaft_width, tool_options.arrow_head_width, tool_options.arrow_head_length), + ShapeType::Heart => Heart::create_node(), ShapeType::Line => Line::create_node(), ShapeType::Rectangle => Rectangle::create_node(), ShapeType::Ellipse => Ellipse::create_node(), @@ -1122,7 +1131,7 @@ impl Fsm for ShapeToolFsmState { let defered_responses = &mut VecDeque::new(); match tool_data.current_shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => { + ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse | ShapeType::Heart => { defered_responses.add(GraphOperationMessage::TransformSet { layer, transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position), @@ -1186,6 +1195,7 @@ impl Fsm for ShapeToolFsmState { ShapeType::Spiral => Spiral::update_shape(document, input, viewport, layer, tool_data, responses), ShapeType::Grid => Grid::update_shape(document, input, layer, tool_options.grid_type, tool_data, modifier, responses), ShapeType::Arrow => Arrow::update_shape(document, input, viewport, layer, tool_data, modifier, responses), + ShapeType::Heart => Heart::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Line => Line::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Rectangle => Rectangle::update_shape(document, input, viewport, layer, tool_data, modifier, responses), ShapeType::Ellipse => Ellipse::update_shape(document, input, viewport, layer, tool_data, modifier, responses), @@ -1454,13 +1464,20 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque vec![HintGroup(vec![ + HintInfo::mouse(MouseMotion::LmbDrag, "Draw Heart"), + HintInfo::keys([Key::Shift], "Constrain Regular").prepend_plus(), + HintInfo::keys([Key::Alt], "From Center").prepend_plus(), + ])], }; HintData(hint_groups) } ShapeToolFsmState::Drawing(shape) => { let mut common_hint_group = vec![HintGroup(vec![HintInfo::mouse(MouseMotion::Rmb, ""), HintInfo::keys([Key::Escape], "Cancel").prepend_slash()])]; let tool_hint_group = match shape { - ShapeType::Polygon | ShapeType::Star | ShapeType::Arc => HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]), + ShapeType::Polygon | ShapeType::Star | ShapeType::Arc | ShapeType::Heart => { + HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]) + } ShapeType::Circle => HintGroup(vec![HintInfo::keys([Key::Alt], "From Center")]), ShapeType::Spiral => HintGroup(vec![]), ShapeType::Grid => HintGroup(vec![HintInfo::keys([Key::Shift], "Constrain Regular"), HintInfo::keys([Key::Alt], "From Center")]), diff --git a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs index 33c6265cee..f41ba70910 100644 --- a/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs +++ b/node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs @@ -183,6 +183,79 @@ pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius polyline_bezpath(positions, true) } +/// Proportional controls for [`heart_bezpath`]. Lengths are fractions of the heart's radius and angles are +/// in radians, so a heart keeps its shape at any size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct HeartProportions { + /// How far the top V dips below the upper bound of the heart. + pub cleavage_depth: f64, + /// Half-angle of the top V. Zero produces a needle-sharp notch with vertical tangents; larger angles open it into a smooth join. + pub cleavage_angle: f64, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + pub lobe_fullness: f64, + /// Vertical position of the side anchor (positive raises the shoulder). + pub shoulder_height: f64, + /// Horizontal position of the side anchor. + pub shoulder_width: f64, + /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. + pub shoulder_tilt: f64, + /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. + pub upper_curvature: f64, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + pub lower_curvature: f64, + /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. + pub point_sharpness: f64, + /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. + pub taper_length: f64, +} + +/// Constructs a heart from a `radius` and a set of proportional controls. The path is closed and runs +/// clockwise from the top cusp: top, right shoulder, bottom point, left shoulder. The two cusps are sharp +/// joins; the shoulders are G1-continuous. The left half is a mirror of the right, so the shape is always +/// symmetric about the vertical axis through `center`. +pub fn heart_bezpath(center: DVec2, radius: f64, proportions: HeartProportions) -> BezPath { + let HeartProportions { + cleavage_depth, + cleavage_angle, + lobe_fullness, + shoulder_height, + shoulder_width, + shoulder_tilt, + upper_curvature, + lower_curvature, + point_sharpness, + taper_length, + } = proportions; + + // Anchors for the right half plus the two y-axis cusps, in normalized coordinates (y points downward). + let top = DVec2::new(0., -1. + cleavage_depth); + let shoulder = DVec2::new(shoulder_width, -shoulder_height); + let bottom = DVec2::new(0., 1.); + + // Unit tangent directions, all measured from the upward vertical. + let top_direction = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); + let bottom_direction = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); + let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); + + // Cubic Bezier control points for the right half. + let top_out = top + top_direction * lobe_fullness; + let shoulder_in = shoulder + shoulder_up * upper_curvature; + let shoulder_out = shoulder - shoulder_up * lower_curvature; + let bottom_in = bottom + bottom_direction * taper_length; + + let place = |point: DVec2| center + point * radius; + let mirror = |point: DVec2| DVec2::new(-point.x, point.y); + + let anchors = [ + Anchor::new(place(top), Some(place(mirror(top_out))), Some(place(top_out))), + Anchor::new(place(shoulder), Some(place(shoulder_in)), Some(place(shoulder_out))), + Anchor::new(place(bottom), Some(place(bottom_in)), Some(place(mirror(bottom_in)))), + Anchor::new(place(mirror(shoulder)), Some(place(mirror(shoulder_out))), Some(place(mirror(shoulder_in)))), + ]; + + bezpath_from_anchors(&anchors, true) +} + /// Constructs a line from `point1` to `point2`. pub fn line_bezpath(point1: DVec2, point2: DVec2) -> BezPath { polyline_bezpath([point1, point2], false) @@ -343,3 +416,89 @@ fn archimedean_spiral_arc_length_origin(theta: f64, a: f64, b: f64) -> f64 { let sqrt_term = (r * r + b * b).sqrt(); (r * sqrt_term + b * b * ((r + sqrt_term).ln())) / (2. * b) } + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::ParamCurve; + use kurbo::{PathEl, Shape}; + + fn default_heart() -> HeartProportions { + HeartProportions { + cleavage_depth: 0.2, + cleavage_angle: 45_f64.to_radians(), + lobe_fullness: 0.55, + shoulder_height: 0.5, + shoulder_width: 1., + shoulder_tilt: 0., + upper_curvature: 0.55, + lower_curvature: 1., + point_sharpness: 30_f64.to_radians(), + taper_length: 0.7, + } + } + + #[test] + fn heart_is_a_closed_path_of_four_curves() { + let bezpath = heart_bezpath(DVec2::ZERO, 50., default_heart()); + let elements: Vec<_> = bezpath.elements().to_vec(); + + assert!(matches!(elements.first(), Some(PathEl::MoveTo(_)))); + assert!(matches!(elements.last(), Some(PathEl::ClosePath))); + assert_eq!(elements.iter().filter(|element| matches!(element, PathEl::CurveTo(..))).count(), 4); + } + + #[test] + fn heart_is_symmetric_about_the_vertical_axis() { + let center = DVec2::new(7., -3.); + let bezpath = heart_bezpath(center, 50., default_heart()); + + // Sample the *curve*, not the control net. Checking the control points against each other proves + // nothing here: the left half is built by mirroring the right, so every control point has a mirrored + // twin by construction and the assertion cannot fail. Flattening tests what the control points were + // assembled into, which is where an ordering mistake would actually show up. + const STEPS: usize = 64; + let samples: Vec = bezpath + .segments() + .flat_map(|segment| (0..=STEPS).map(move |step| segment.eval(step as f64 / STEPS as f64))) + .map(|point| DVec2::new(point.x, point.y)) + .collect(); + assert!(samples.len() > 64, "expected a densely sampled outline, got {} points", samples.len()); + + // The outline must sit symmetrically about the centre, not merely be built from mirrored inputs. + for point in &samples { + let mirrored = DVec2::new(2. * center.x - point.x, point.y); + let nearest = samples.iter().map(|other| other.distance(mirrored)).fold(f64::INFINITY, f64::min); + assert!(nearest < 0.5, "outline point {point:?} has no counterpart across the axis (nearest {nearest})"); + } + + // And the extremes must balance, which catches a half that is mirrored but misplaced. + let left = samples.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let right = samples.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max); + assert!(((left + right) / 2. - center.x).abs() < 1e-9, "outline is not centred: [{left}, {right}] about {}", center.x); + } + + #[test] + fn heart_scales_linearly_with_radius() { + let small = heart_bezpath(DVec2::ZERO, 1., default_heart()); + let large = heart_bezpath(DVec2::ZERO, 50., default_heart()); + + let small_box = small.bounding_box(); + let large_box = large.bounding_box(); + + assert!((large_box.width() - small_box.width() * 50.).abs() < 1e-9); + assert!((large_box.height() - small_box.height() * 50.).abs() < 1e-9); + } + + #[test] + fn heart_respects_its_center() { + let origin = heart_bezpath(DVec2::ZERO, 20., default_heart()); + let offset = heart_bezpath(DVec2::new(100., -40.), 20., default_heart()); + + let origin_box = origin.bounding_box(); + let offset_box = offset.bounding_box(); + + assert!((offset_box.center().x - (origin_box.center().x + 100.)).abs() < 1e-9); + assert!((offset_box.center().y - (origin_box.center().y - 40.)).abs() < 1e-9); + } +} diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index a77b5c98eb..def5b2ce2a 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -172,6 +172,85 @@ fn regular_polygon( Item::new_from_element(Vector::from_bezpath(shapes::regular_polygon_bezpath(DVec2::ZERO, points, *radius.element()))) } +/// Generates a heart shape with parametric control over the cleavage, lobes, shoulders, and bottom point. +#[node_macro::node(category("Vector: Shape"))] +fn heart( + _: impl Ctx, + _primary: (), + #[unit(" px")] + #[default(50)] + radius: Item, + /// How far the top V dips below the upper bound of the heart. + #[default(0.2)] + #[range] + #[hard(0..0.6)] + cleavage_depth: Item, + /// Half-angle of the top V. Zero produces a needle-sharp notch with vertical tangents; larger angles open it into a smooth join. + #[default(45.)] + #[range] + #[hard(0..89)] + cleavage_angle: Item, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + #[default(0.55)] + #[range] + #[hard(0..1.2)] + lobe_fullness: Item, + /// Vertical position of the side anchor (positive raises the shoulder). + #[default(0.5)] + #[range] + #[hard(-0.5..0.9)] + shoulder_height: Item, + /// Horizontal position of the side anchor. + #[default(1.)] + #[range] + #[hard(0..1.4)] + shoulder_width: Item, + /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. + #[default(0.)] + #[range] + #[hard(-60..60)] + shoulder_tilt: Item, + /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. + #[default(0.55)] + #[range] + #[hard(0..1.2)] + upper_curvature: Item, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + #[default(1.)] + #[range] + #[hard(0..1.5)] + lower_curvature: Item, + /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. + #[default(30.)] + #[range] + #[hard(0..89)] + point_sharpness: Item, + /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. + #[default(0.7)] + #[range] + #[hard(0..1.2)] + taper_length: Item, +) -> Item { + let bezpath = shapes::heart_bezpath( + DVec2::ZERO, + *radius.element(), + shapes::HeartProportions { + cleavage_depth: *cleavage_depth.element(), + cleavage_angle: cleavage_angle.element().to_radians(), + lobe_fullness: *lobe_fullness.element(), + shoulder_height: *shoulder_height.element(), + shoulder_width: *shoulder_width.element(), + shoulder_tilt: shoulder_tilt.element().to_radians(), + upper_curvature: *upper_curvature.element(), + lower_curvature: *lower_curvature.element(), + point_sharpness: point_sharpness.element().to_radians(), + taper_length: *taper_length.element(), + }, + ); + + Item::new_from_element(Vector::from_bezpath(bezpath)) +} + /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. #[node_macro::node(category("Vector: Shape"))] fn star(