Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeId> {
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<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::grid::IDENTIFIER))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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::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<Message>,
) {
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, 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,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down
25 changes: 21 additions & 4 deletions editor/src/messages/tool/tool_messages/shape_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 => {
Expand All @@ -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(),
Expand All @@ -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),
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -1454,13 +1464,20 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque<Mess
HintInfo::keys([Key::Shift], "Constrain Circular").prepend_plus(),
HintInfo::keys([Key::Alt], "From Center").prepend_plus(),
])],
ShapeType::Heart => 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")]),
Expand Down
150 changes: 150 additions & 0 deletions node-graph/libraries/vector-types/src/vector/algorithms/shapes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<DVec2> = 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);
}
}
Loading
Loading