From 5204430eff582a3fe3e7e80e60d1102f9edbe597 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Thu, 7 May 2026 01:42:22 -0700 Subject: [PATCH 1/4] Heart node --- .../nodes/vector/src/generator_nodes.rs | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index a77b5c98eb..98ff556db8 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -172,6 +172,110 @@ 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: f64, + /// How far the top V dips below the upper bound of the heart. + #[default(0.2)] + #[range((0., 0.6))] + #[hard_min(0.)] + #[hard_max(0.6)] + cleavage_depth: f64, + /// Half-angle of the top V. Zero collapses the V into a smooth join. + #[default(45.)] + #[range((0., 89.))] + #[hard_min(0.)] + #[hard_max(89.)] + cleavage_angle: Angle, + /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. + #[default(0.55)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + lobe_fullness: f64, + /// Vertical position of the side anchor (positive raises the shoulder). + #[default(0.5)] + #[range((-0.5, 0.9))] + #[hard_min(-0.5)] + #[hard_max(0.9)] + shoulder_height: f64, + /// Horizontal position of the side anchor. + #[default(1.)] + #[range((0., 1.4))] + #[hard_min(0.)] + #[hard_max(1.4)] + shoulder_width: f64, + /// Rotation of the shoulder tangent from vertical. Positive leans the shoulder outward at top. + #[default(0.)] + #[range((-60., 60.))] + #[hard_min(-60.)] + #[hard_max(60.)] + shoulder_tilt: Angle, + /// Tangent length at the shoulder going up, controlling the curvature of the upper lobe side. + #[default(0.55)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + upper_curvature: f64, + /// Tangent length at the shoulder going down, controlling the curvature of the lower side. + #[default(1.)] + #[range((0., 1.5))] + #[hard_min(0.)] + #[hard_max(1.5)] + lower_curvature: f64, + /// Half-angle of the bottom V. Zero produces a needle-sharp point with vertical tangents. + #[default(30.)] + #[range((0., 89.))] + #[hard_min(0.)] + #[hard_max(89.)] + point_sharpness: Angle, + /// Tangent length arriving at the bottom cusp, controlling how the sides taper into the point. + #[default(0.7)] + #[range((0., 1.2))] + #[hard_min(0.)] + #[hard_max(1.2)] + taper_length: f64, +) -> Table { + let cleavage_angle = cleavage_angle.to_radians(); + let point_sharpness = point_sharpness.to_radians(); + let shoulder_tilt = shoulder_tilt.to_radians(); + + // Anchor points for the right half plus the 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_dir = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); + let bottom_dir_out = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); + let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); + let shoulder_down = -shoulder_up; + + // Cubic Bezier control points for the right half. + let c1 = top + top_dir * lobe_fullness; + let c2 = shoulder + shoulder_up * upper_curvature; + let c3 = shoulder + shoulder_down * lower_curvature; + let c4 = bottom + bottom_dir_out * taper_length; + + let mirror = |p: DVec2| DVec2::new(-p.x, p.y); + + // Closed clockwise path: T → S → B → S' → T. Joins at T and B are sharp; joins at the shoulders are G1. + let manipulator_groups = [ + subpath::ManipulatorGroup::new(top * radius, Some(mirror(c1) * radius), Some(c1 * radius)), + subpath::ManipulatorGroup::new(shoulder * radius, Some(c2 * radius), Some(c3 * radius)), + subpath::ManipulatorGroup::new(bottom * radius, Some(c4 * radius), Some(mirror(c4) * radius)), + subpath::ManipulatorGroup::new(mirror(shoulder) * radius, Some(mirror(c3) * radius), Some(mirror(c2) * radius)), + ] + .to_vec(); + + Table::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) +} + /// 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( From 0e5278986c88463ab04d21961449683aaecb4f28 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Mon, 25 May 2026 15:06:23 +0530 Subject: [PATCH 2/4] Add Heart drawing mode to the Shape tool with gizmo registration --- .../gizmos/gizmo_manager.rs | 15 +++ .../graph_modification_utils.rs | 4 + .../shapes/heart_shape.rs | 113 ++++++++++++++++++ .../tool/common_functionality/shapes/mod.rs | 1 + .../shapes/shape_utility.rs | 8 +- .../messages/tool/tool_messages/shape_tool.rs | 25 +++- .../nodes/vector/src/generator_nodes.rs | 4 +- 7 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 editor/src/messages/tool/common_functionality/shapes/heart_shape.rs diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 962e9a5d06..3277dbfebb 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -8,6 +8,7 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::circle_shape::CircleGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; +use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; use crate::messages::tool::common_functionality::shapes::polygon_shape::PolygonGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; @@ -32,6 +33,7 @@ pub enum ShapeGizmoHandlers { Circle(CircleGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), + Heart(HeartGizmoHandler), } impl ShapeGizmoHandlers { @@ -45,6 +47,7 @@ impl ShapeGizmoHandlers { Self::Circle(_) => "circle", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", + Self::Heart(_) => "heart", Self::None => "none", } } @@ -58,6 +61,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), + Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -71,6 +75,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), + Self::Heart(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -84,6 +89,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), + Self::Heart(h) => h.handle_click(), Self::None => {} } } @@ -97,6 +103,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), + Self::Heart(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -110,6 +117,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), + Self::Heart(h) => h.cleanup(), Self::None => {} } } @@ -131,6 +139,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), + Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -151,6 +160,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), + Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -163,6 +173,7 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), + Self::Heart(h) => h.mouse_cursor_icon(), Self::None => None, } } @@ -214,6 +225,10 @@ impl GizmoManager { if graph_modification_utils::get_spiral_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Spiral(SpiralGizmoHandler::default())); } + // Heart + if graph_modification_utils::get_heart_id(layer, &document.network_interface).is_some() { + return Some(ShapeGizmoHandlers::Heart(HeartGizmoHandler::default())); + } None } 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..2fc5d4fffd --- /dev/null +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -0,0 +1,113 @@ +use crate::messages::frontend::utility_types::MouseCursorIcon; +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::overlays::utility_types::OverlayContext; +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::shape_editor::ShapeState; +use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, 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; + +/// Placeholder gizmo handler for the Heart shape. +/// The heart's parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. +#[derive(Clone, Debug, Default)] +pub struct HeartGizmoHandler; + +impl ShapeGizmoHandler for HeartGizmoHandler { + fn is_any_gizmo_hovered(&self) -> bool { + false + } + + fn handle_state(&mut self, _layer: LayerNodeIdentifier, _mouse_position: DVec2, _document: &DocumentMessageHandler, _responses: &mut VecDeque) {} + + fn handle_click(&mut self) {} + + fn handle_update(&mut self, _drag_start: DVec2, _document: &DocumentMessageHandler, _input: &InputPreprocessorMessageHandler, _responses: &mut VecDeque) {} + + fn overlays( + &self, + _document: &DocumentMessageHandler, + _selected_layer: Option, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + _mouse_position: DVec2, + _overlay_context: &mut OverlayContext, + ) { + } + + fn dragging_overlays( + &self, + _document: &DocumentMessageHandler, + _input: &InputPreprocessorMessageHandler, + _shape_editor: &mut &mut ShapeState, + _mouse_position: DVec2, + _overlay_context: &mut OverlayContext, + ) { + } + + fn cleanup(&mut self) {} + + fn mouse_cursor_icon(&self) -> Option { + None + } +} + +#[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; + }; + + let dimensions = (start - end).abs(); + + 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, 1), + 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/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index 98ff556db8..e585b0425a 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -240,7 +240,7 @@ fn heart( #[hard_min(0.)] #[hard_max(1.2)] taper_length: f64, -) -> Table { +) -> List { let cleavage_angle = cleavage_angle.to_radians(); let point_sharpness = point_sharpness.to_radians(); let shoulder_tilt = shoulder_tilt.to_radians(); @@ -273,7 +273,7 @@ fn heart( ] .to_vec(); - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + List::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. From 9aaef5f60626bc46ed14a3f4c25ade0d1ab2ea91 Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Fri, 24 Jul 2026 07:42:16 +0530 Subject: [PATCH 3/4] Migrate the Heart node to the ranked node input API --- .../nodes/vector/src/generator_nodes.rs | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index e585b0425a..660b61ecfb 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -179,71 +179,69 @@ fn heart( _primary: (), #[unit(" px")] #[default(50)] - radius: f64, + radius: Item, /// How far the top V dips below the upper bound of the heart. #[default(0.2)] - #[range((0., 0.6))] - #[hard_min(0.)] - #[hard_max(0.6)] - cleavage_depth: f64, + #[range] + #[hard(0..0.6)] + cleavage_depth: Item, /// Half-angle of the top V. Zero collapses the V into a smooth join. #[default(45.)] - #[range((0., 89.))] - #[hard_min(0.)] - #[hard_max(89.)] - cleavage_angle: Angle, + #[range] + #[hard(0..89)] + cleavage_angle: Item, /// Tangent length leaving the top cusp, controlling the upper roundness of each lobe. #[default(0.55)] - #[range((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - lobe_fullness: f64, + #[range] + #[hard(0..1.2)] + lobe_fullness: Item, /// Vertical position of the side anchor (positive raises the shoulder). #[default(0.5)] - #[range((-0.5, 0.9))] - #[hard_min(-0.5)] - #[hard_max(0.9)] - shoulder_height: f64, + #[range] + #[hard(-0.5..0.9)] + shoulder_height: Item, /// Horizontal position of the side anchor. #[default(1.)] - #[range((0., 1.4))] - #[hard_min(0.)] - #[hard_max(1.4)] - shoulder_width: f64, + #[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((-60., 60.))] - #[hard_min(-60.)] - #[hard_max(60.)] - shoulder_tilt: Angle, + #[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((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - upper_curvature: f64, + #[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((0., 1.5))] - #[hard_min(0.)] - #[hard_max(1.5)] - lower_curvature: f64, + #[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((0., 89.))] - #[hard_min(0.)] - #[hard_max(89.)] - point_sharpness: Angle, + #[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((0., 1.2))] - #[hard_min(0.)] - #[hard_max(1.2)] - taper_length: f64, -) -> List { - let cleavage_angle = cleavage_angle.to_radians(); - let point_sharpness = point_sharpness.to_radians(); - let shoulder_tilt = shoulder_tilt.to_radians(); + #[range] + #[hard(0..1.2)] + taper_length: Item, +) -> Item { + let radius = *radius.element(); + let cleavage_depth = *cleavage_depth.element(); + let lobe_fullness = *lobe_fullness.element(); + let shoulder_height = *shoulder_height.element(); + let shoulder_width = *shoulder_width.element(); + let upper_curvature = *upper_curvature.element(); + let lower_curvature = *lower_curvature.element(); + let taper_length = *taper_length.element(); + let cleavage_angle = cleavage_angle.element().to_radians(); + let point_sharpness = point_sharpness.element().to_radians(); + let shoulder_tilt = shoulder_tilt.element().to_radians(); // Anchor points for the right half plus the y-axis cusps, in normalized coordinates (y points downward). let top = DVec2::new(0., -1. + cleavage_depth); @@ -273,7 +271,7 @@ fn heart( ] .to_vec(); - List::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + Item::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) } /// Generates an n-pointed star shape with inner and outer points at chosen radii from the center. From 8478c2792fe15507ed5f65ab612dc3f8896bd27e Mon Sep 17 00:00:00 2001 From: Ayush Amawate Date: Wed, 26 Aug 2026 07:16:53 +0530 Subject: [PATCH 4/4] Port the Heart node's geometry to BezPath and cover it with tests --- .../gizmos/gizmo_manager.rs | 15 -- .../shapes/heart_shape.rs | 55 +------ .../src/vector/algorithms/shapes.rs | 150 ++++++++++++++++++ .../nodes/vector/src/generator_nodes.rs | 59 +++---- 4 files changed, 174 insertions(+), 105 deletions(-) diff --git a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs index 3277dbfebb..962e9a5d06 100644 --- a/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs +++ b/editor/src/messages/tool/common_functionality/gizmos/gizmo_manager.rs @@ -8,7 +8,6 @@ use crate::messages::tool::common_functionality::shape_editor::ShapeState; use crate::messages::tool::common_functionality::shapes::arc_shape::ArcGizmoHandler; use crate::messages::tool::common_functionality::shapes::circle_shape::CircleGizmoHandler; use crate::messages::tool::common_functionality::shapes::grid_shape::GridGizmoHandler; -use crate::messages::tool::common_functionality::shapes::heart_shape::HeartGizmoHandler; use crate::messages::tool::common_functionality::shapes::polygon_shape::PolygonGizmoHandler; use crate::messages::tool::common_functionality::shapes::shape_utility::ShapeGizmoHandler; use crate::messages::tool::common_functionality::shapes::spiral_shape::SpiralGizmoHandler; @@ -33,7 +32,6 @@ pub enum ShapeGizmoHandlers { Circle(CircleGizmoHandler), Grid(GridGizmoHandler), Spiral(SpiralGizmoHandler), - Heart(HeartGizmoHandler), } impl ShapeGizmoHandlers { @@ -47,7 +45,6 @@ impl ShapeGizmoHandlers { Self::Circle(_) => "circle", Self::Grid(_) => "grid", Self::Spiral(_) => "spiral", - Self::Heart(_) => "heart", Self::None => "none", } } @@ -61,7 +58,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_state(layer, mouse_position, document, responses), Self::Grid(h) => h.handle_state(layer, mouse_position, document, responses), Self::Spiral(h) => h.handle_state(layer, mouse_position, document, responses), - Self::Heart(h) => h.handle_state(layer, mouse_position, document, responses), Self::None => {} } } @@ -75,7 +71,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.is_any_gizmo_hovered(), Self::Grid(h) => h.is_any_gizmo_hovered(), Self::Spiral(h) => h.is_any_gizmo_hovered(), - Self::Heart(h) => h.is_any_gizmo_hovered(), Self::None => false, } } @@ -89,7 +84,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_click(), Self::Grid(h) => h.handle_click(), Self::Spiral(h) => h.handle_click(), - Self::Heart(h) => h.handle_click(), Self::None => {} } } @@ -103,7 +97,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.handle_update(drag_start, document, input, responses), Self::Grid(h) => h.handle_update(drag_start, document, input, responses), Self::Spiral(h) => h.handle_update(drag_start, document, input, responses), - Self::Heart(h) => h.handle_update(drag_start, document, input, responses), Self::None => {} } } @@ -117,7 +110,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.cleanup(), Self::Grid(h) => h.cleanup(), Self::Spiral(h) => h.cleanup(), - Self::Heart(h) => h.cleanup(), Self::None => {} } } @@ -139,7 +131,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), - Self::Heart(h) => h.overlays(document, layer, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -160,7 +151,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Grid(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::Spiral(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), - Self::Heart(h) => h.dragging_overlays(document, input, shape_editor, mouse_position, overlay_context), Self::None => {} } } @@ -173,7 +163,6 @@ impl ShapeGizmoHandlers { Self::Circle(h) => h.mouse_cursor_icon(), Self::Grid(h) => h.mouse_cursor_icon(), Self::Spiral(h) => h.mouse_cursor_icon(), - Self::Heart(h) => h.mouse_cursor_icon(), Self::None => None, } } @@ -225,10 +214,6 @@ impl GizmoManager { if graph_modification_utils::get_spiral_id(layer, &document.network_interface).is_some() { return Some(ShapeGizmoHandlers::Spiral(SpiralGizmoHandler::default())); } - // Heart - if graph_modification_utils::get_heart_id(layer, &document.network_interface).is_some() { - return Some(ShapeGizmoHandlers::Heart(HeartGizmoHandler::default())); - } None } diff --git a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs index 2fc5d4fffd..6a8e5813ec 100644 --- a/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs +++ b/editor/src/messages/tool/common_functionality/shapes/heart_shape.rs @@ -1,14 +1,11 @@ -use crate::messages::frontend::utility_types::MouseCursorIcon; 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::overlays::utility_types::OverlayContext; 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::shape_editor::ShapeState; -use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeGizmoHandler, ShapeToolModifierKey}; +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; @@ -16,50 +13,10 @@ use graph_craft::document::NodeInput; use graph_craft::document::value::TaggedValue; use std::collections::VecDeque; -/// Placeholder gizmo handler for the Heart shape. -/// The heart's parametric controls (cleavage, lobes, shoulder, etc.) are adjusted via the Properties panel. -#[derive(Clone, Debug, Default)] -pub struct HeartGizmoHandler; - -impl ShapeGizmoHandler for HeartGizmoHandler { - fn is_any_gizmo_hovered(&self) -> bool { - false - } - - fn handle_state(&mut self, _layer: LayerNodeIdentifier, _mouse_position: DVec2, _document: &DocumentMessageHandler, _responses: &mut VecDeque) {} - - fn handle_click(&mut self) {} - - fn handle_update(&mut self, _drag_start: DVec2, _document: &DocumentMessageHandler, _input: &InputPreprocessorMessageHandler, _responses: &mut VecDeque) {} - - fn overlays( - &self, - _document: &DocumentMessageHandler, - _selected_layer: Option, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - _overlay_context: &mut OverlayContext, - ) { - } - - fn dragging_overlays( - &self, - _document: &DocumentMessageHandler, - _input: &InputPreprocessorMessageHandler, - _shape_editor: &mut &mut ShapeState, - _mouse_position: DVec2, - _overlay_context: &mut OverlayContext, - ) { - } - - fn cleanup(&mut self) {} - - fn mouse_cursor_icon(&self) -> Option { - None - } -} - +/// 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; @@ -98,7 +55,7 @@ impl Heart { } responses.add(NodeGraphMessage::SetInput { - input_connector: InputConnector::node(node_id, 1), + input_connector: InputConnector::node(node_id, graphene_std::vector::generator_nodes::heart::RadiusInput), input: NodeInput::value(TaggedValue::F64(radius), false), }); 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..63fb23dd0e 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 collapses the V 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,80 @@ 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::{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 bezpath = heart_bezpath(DVec2::ZERO, 50., default_heart()); + + // Every point on the path must have a mirrored twin, since the left half is built by mirroring the right. + let points: Vec = bezpath + .elements() + .iter() + .flat_map(|element| match element { + PathEl::MoveTo(p) | PathEl::LineTo(p) => vec![DVec2::new(p.x, p.y)], + PathEl::QuadTo(a, b) => vec![DVec2::new(a.x, a.y), DVec2::new(b.x, b.y)], + PathEl::CurveTo(a, b, c) => vec![DVec2::new(a.x, a.y), DVec2::new(b.x, b.y), DVec2::new(c.x, c.y)], + PathEl::ClosePath => vec![], + }) + .collect(); + + for point in &points { + let mirrored = DVec2::new(-point.x, point.y); + assert!(points.iter().any(|other| other.distance(mirrored) < 1e-9), "no mirrored counterpart for {point:?}"); + } + } + + #[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 660b61ecfb..7a9e835393 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -231,47 +231,24 @@ fn heart( #[hard(0..1.2)] taper_length: Item, ) -> Item { - let radius = *radius.element(); - let cleavage_depth = *cleavage_depth.element(); - let lobe_fullness = *lobe_fullness.element(); - let shoulder_height = *shoulder_height.element(); - let shoulder_width = *shoulder_width.element(); - let upper_curvature = *upper_curvature.element(); - let lower_curvature = *lower_curvature.element(); - let taper_length = *taper_length.element(); - let cleavage_angle = cleavage_angle.element().to_radians(); - let point_sharpness = point_sharpness.element().to_radians(); - let shoulder_tilt = shoulder_tilt.element().to_radians(); - - // Anchor points for the right half plus the 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_dir = DVec2::new(cleavage_angle.sin(), -cleavage_angle.cos()); - let bottom_dir_out = DVec2::new(point_sharpness.sin(), -point_sharpness.cos()); - let shoulder_up = DVec2::new(shoulder_tilt.sin(), -shoulder_tilt.cos()); - let shoulder_down = -shoulder_up; - - // Cubic Bezier control points for the right half. - let c1 = top + top_dir * lobe_fullness; - let c2 = shoulder + shoulder_up * upper_curvature; - let c3 = shoulder + shoulder_down * lower_curvature; - let c4 = bottom + bottom_dir_out * taper_length; - - let mirror = |p: DVec2| DVec2::new(-p.x, p.y); - - // Closed clockwise path: T → S → B → S' → T. Joins at T and B are sharp; joins at the shoulders are G1. - let manipulator_groups = [ - subpath::ManipulatorGroup::new(top * radius, Some(mirror(c1) * radius), Some(c1 * radius)), - subpath::ManipulatorGroup::new(shoulder * radius, Some(c2 * radius), Some(c3 * radius)), - subpath::ManipulatorGroup::new(bottom * radius, Some(c4 * radius), Some(mirror(c4) * radius)), - subpath::ManipulatorGroup::new(mirror(shoulder) * radius, Some(mirror(c3) * radius), Some(mirror(c2) * radius)), - ] - .to_vec(); - - Item::new_from_element(Vector::from_subpath(subpath::Subpath::new(manipulator_groups, true))) + 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.