Add "Spiral" to the Shape tool and as a new node (#2803)
Some checks are pending
Editor: Dev & CI / build (push) Waiting to run
Editor: Dev & CI / cargo-deny (push) Waiting to run
Website / build (push) Waiting to run

* made spiral node

* number of turns in decimal and arc-angle implementation

* logarithmic spiral

* unified log and arc spiral into spiral node

* add spiral shape in shape tool

* fix min value and degree unit

* make it compile

* updated the api

* changed the function_name

* [/] to update the turns widget in shape tool

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
0SlowPoke0 2025-09-09 03:07:43 +05:30 committed by GitHub
parent ee586be381
commit 485152bf8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 483 additions and 44 deletions

View file

@ -1776,6 +1776,7 @@ fn static_node_properties() -> NodeProperties {
map.insert("math_properties".to_string(), Box::new(node_properties::math_properties));
map.insert("rectangle_properties".to_string(), Box::new(node_properties::rectangle_properties));
map.insert("grid_properties".to_string(), Box::new(node_properties::grid_properties));
map.insert("spiral_properties".to_string(), Box::new(node_properties::spiral_properties));
map.insert("sample_polyline_properties".to_string(), Box::new(node_properties::sample_polyline_properties));
map.insert(
"monitor_properties".to_string(),
@ -2394,6 +2395,7 @@ impl DocumentNodeDefinition {
/// `input_override` does not have to be the correct length.
pub fn node_template_input_override(&self, input_override: impl IntoIterator<Item = Option<NodeInput>>) -> NodeTemplate {
let mut template = self.node_template.clone();
// TODO: Replace the .enumerate() with changing the iterator to take a tuple of (index, input) so the user is forced to provide the correct index
input_override.into_iter().enumerate().for_each(|(index, input_override)| {
if let Some(input_override) = input_override {
// Only value inputs can be overridden, since node inputs change graph structure and must be handled by the network interface

View file

@ -23,7 +23,7 @@ use graphene_std::raster::{
use graphene_std::table::{Table, TableRow};
use graphene_std::text::{Font, TextAlign};
use graphene_std::transform::{Footprint, ReferencePoint, Transform};
use graphene_std::vector::misc::{ArcType, CentroidType, GridType, MergeByDistanceAlgorithm, PointSpacingType};
use graphene_std::vector::misc::{ArcType, CentroidType, GridType, MergeByDistanceAlgorithm, PointSpacingType, SpiralType};
use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
@ -1286,6 +1286,66 @@ pub(crate) fn grid_properties(node_id: NodeId, context: &mut NodePropertiesConte
widgets
}
pub(crate) fn spiral_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::vector::generator_nodes::spiral::*;
let spiral_type = enum_choice::<SpiralType>()
.for_socket(ParameterWidgetsInfo::new(node_id, SpiralTypeInput::INDEX, true, context))
.property_row();
let turns = number_widget(ParameterWidgetsInfo::new(node_id, TurnsInput::INDEX, true, context), NumberInput::default().min(0.1));
let start_angle = number_widget(ParameterWidgetsInfo::new(node_id, StartAngleInput::INDEX, true, context), NumberInput::default().unit("°"));
let mut widgets = vec![spiral_type, LayoutGroup::Row { widgets: turns }, LayoutGroup::Row { widgets: start_angle }];
let document_node = match get_document_node(node_id, context) {
Ok(document_node) => document_node,
Err(err) => {
log::error!("Could not get document node in exposure_properties: {err}");
return Vec::new();
}
};
let Some(spiral_type_input) = document_node.inputs.get(SpiralTypeInput::INDEX) else {
log::warn!("A widget failed to be built because its node's input index is invalid.");
return vec![];
};
if let Some(&TaggedValue::SpiralType(spiral_type)) = spiral_type_input.as_non_exposed_value() {
match spiral_type {
SpiralType::Archimedean => {
let inner_radius = LayoutGroup::Row {
widgets: number_widget(ParameterWidgetsInfo::new(node_id, InnerRadiusInput::INDEX, true, context), NumberInput::default().min(0.).unit(" px")),
};
let outer_radius = LayoutGroup::Row {
widgets: number_widget(ParameterWidgetsInfo::new(node_id, OuterRadiusInput::INDEX, true, context), NumberInput::default().unit(" px")),
};
widgets.extend([inner_radius, outer_radius]);
}
SpiralType::Logarithmic => {
let inner_radius = LayoutGroup::Row {
widgets: number_widget(ParameterWidgetsInfo::new(node_id, InnerRadiusInput::INDEX, true, context), NumberInput::default().min(0.).unit(" px")),
};
let outer_radius = LayoutGroup::Row {
widgets: number_widget(ParameterWidgetsInfo::new(node_id, OuterRadiusInput::INDEX, true, context), NumberInput::default().min(0.1).unit(" px")),
};
widgets.extend([inner_radius, outer_radius]);
}
}
}
let angular_resolution = number_widget(
ParameterWidgetsInfo::new(node_id, AngularResolutionInput::INDEX, true, context),
NumberInput::default().min(1.).max(180.).unit("°"),
);
widgets.push(LayoutGroup::Row { widgets: angular_resolution });
widgets
}
pub(crate) const SAMPLE_POLYLINE_TOOLTIP_SPACING: &str = "Use a point sampling density controlled by a distance between, or specific number of, points.";
pub(crate) const SAMPLE_POLYLINE_TOOLTIP_SEPARATION: &str = "Distance between each instance (exact if 'Adaptive Spacing' is disabled, approximate if enabled).";
pub(crate) const SAMPLE_POLYLINE_TOOLTIP_QUANTITY: &str = "Number of points to place along the path.";

View file

@ -363,6 +363,10 @@ pub fn get_arc_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInt
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name("Arc")
}
pub fn get_spiral_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name("Spiral")
}
pub fn get_text_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name("Text")
}

View file

@ -6,6 +6,7 @@ pub mod line_shape;
pub mod polygon_shape;
pub mod rectangle_shape;
pub mod shape_utility;
pub mod spiral_shape;
pub mod star_shape;
pub use super::shapes::ellipse_shape::Ellipse;

View file

@ -158,34 +158,34 @@ impl Polygon {
}
}
pub fn increase_decrease_sides(increase: bool, document: &DocumentMessageHandler, shape_tool_data: &mut ShapeToolData, responses: &mut VecDeque<Message>) {
if let Some(layer) = shape_tool_data.data.layer {
let Some(node_id) = graph_modification_utils::get_polygon_id(layer, &document.network_interface).or(graph_modification_utils::get_star_id(layer, &document.network_interface)) else {
return;
};
/// Updates the number of sides of a polygon or star node and syncs the Shape tool UI widget accordingly.
/// Increases or decreases the side count based on user input, clamped to a minimum of 3.
pub fn decrease_or_increase_sides(decrease: bool, layer: LayerNodeIdentifier, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
let Some(node_id) = graph_modification_utils::get_polygon_id(layer, &document.network_interface).or(graph_modification_utils::get_star_id(layer, &document.network_interface)) else {
return;
};
let Some(node_inputs) = NodeGraphLayer::new(layer, &document.network_interface)
.find_node_inputs("Regular Polygon")
.or(NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs("Star"))
else {
return;
};
let Some(node_inputs) = NodeGraphLayer::new(layer, &document.network_interface)
.find_node_inputs("Regular Polygon")
.or(NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs("Star"))
else {
return;
};
let Some(&TaggedValue::U32(n)) = node_inputs.get(1).unwrap().as_value() else {
return;
};
let Some(&TaggedValue::U32(n)) = node_inputs.get(1).unwrap().as_value() else {
return;
};
let new_dimension = if increase { n + 1 } else { (n - 1).max(3) };
let new_dimension = if decrease { (n - 1).max(3) } else { n + 1 };
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices(new_dimension),
});
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices(new_dimension),
});
responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(node_id, 1),
input: NodeInput::value(TaggedValue::U32(new_dimension), false),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
}
responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(node_id, 1),
input: NodeInput::value(TaggedValue::U32(new_dimension), false),
});
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}

View file

@ -29,6 +29,7 @@ pub enum ShapeType {
Star,
Circle,
Arc,
Spiral,
Grid,
Rectangle,
Ellipse,
@ -43,6 +44,7 @@ impl ShapeType {
Self::Circle => "Circle",
Self::Arc => "Arc",
Self::Grid => "Grid",
Self::Spiral => "Spiral",
Self::Rectangle => "Rectangle",
Self::Ellipse => "Ellipse",
Self::Line => "Line",

View file

@ -0,0 +1,116 @@
use super::*;
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_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::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer};
use crate::messages::tool::common_functionality::snapping::{SnapCandidatePoint, SnapData, SnapTypeConfiguration};
use crate::messages::tool::tool_messages::shape_tool::ShapeOptionsUpdate;
use crate::messages::tool::tool_messages::tool_prelude::*;
use glam::DAffine2;
use graph_craft::document::NodeInput;
use graph_craft::document::value::TaggedValue;
use graphene_std::NodeInputDecleration;
use graphene_std::vector::misc::SpiralType;
use std::collections::VecDeque;
#[derive(Default)]
pub struct Spiral;
impl Spiral {
pub fn create_node(spiral_type: SpiralType, turns: f64) -> NodeTemplate {
let inner_radius = match spiral_type {
SpiralType::Archimedean => 0.,
SpiralType::Logarithmic => 0.1,
};
let node_type = resolve_document_node_type("Spiral").expect("Spiral node can't be found");
node_type.node_template_input_override([
None,
Some(NodeInput::value(TaggedValue::SpiralType(spiral_type), false)),
Some(NodeInput::value(TaggedValue::F64(turns), false)),
Some(NodeInput::value(TaggedValue::F64(0.), false)),
Some(NodeInput::value(TaggedValue::F64(inner_radius), false)),
Some(NodeInput::value(TaggedValue::F64(0.1), false)),
Some(NodeInput::value(TaggedValue::F64(90.), false)),
])
}
pub fn update_shape(document: &DocumentMessageHandler, ipp: &InputPreprocessorMessageHandler, layer: LayerNodeIdentifier, shape_tool_data: &mut ShapeToolData, responses: &mut VecDeque<Message>) {
use graphene_std::vector::generator_nodes::spiral::*;
let viewport_drag_start = shape_tool_data.data.viewport_drag_start(document);
let ignore = vec![layer];
let snap_data = SnapData::ignore(document, ipp, &ignore);
let config = SnapTypeConfiguration::default();
let document_mouse = document.metadata().document_to_viewport.inverse().transform_point2(ipp.mouse.position);
let snapped = shape_tool_data.data.snap_manager.free_snap(&snap_data, &SnapCandidatePoint::handle(document_mouse), config);
let snapped_viewport_point = document.metadata().document_to_viewport.transform_point2(snapped.snapped_point_document);
shape_tool_data.data.snap_manager.update_indicator(snapped);
let dragged_distance = (viewport_drag_start - snapped_viewport_point).length();
let Some(node_id) = graph_modification_utils::get_spiral_id(layer, &document.network_interface) else {
return;
};
let Some(node_inputs) = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs("Spiral") else {
return;
};
let Some(&TaggedValue::SpiralType(spiral_type)) = node_inputs.get(SpiralTypeInput::INDEX).unwrap().as_value() else {
return;
};
let new_radius = match spiral_type {
SpiralType::Archimedean => dragged_distance,
SpiralType::Logarithmic => (dragged_distance).max(0.1),
};
responses.add(GraphOperationMessage::TransformSet {
layer,
transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., viewport_drag_start),
transform_in: TransformIn::Viewport,
skip_rerender: false,
});
responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(node_id, OuterRadiusInput::INDEX),
input: NodeInput::value(TaggedValue::F64(new_radius), false),
});
}
/// Updates the number of turns of a Spiral node and recalculates its radius based on drag distance.
/// Also updates the Shape tool's turns UI widget to reflect the change.
pub fn update_turns(decrease: bool, layer: LayerNodeIdentifier, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
use graphene_std::vector::generator_nodes::spiral::*;
let Some(node_inputs) = NodeGraphLayer::new(layer, &document.network_interface).find_node_inputs("Spiral") else {
return;
};
let Some(node_id) = graph_modification_utils::get_spiral_id(layer, &document.network_interface) else {
return;
};
let Some(&TaggedValue::F64(mut turns)) = node_inputs.get(TurnsInput::INDEX).unwrap().as_value() else {
return;
};
if decrease {
turns = (turns - 1.).max(1.);
} else {
turns += 1.;
}
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Turns(turns),
});
responses.add(NodeGraphMessage::SetInput {
input_connector: InputConnector::node(node_id, TurnsInput::INDEX),
input: NodeInput::value(TaggedValue::F64(turns), false),
});
}
}

View file

@ -14,6 +14,7 @@ use crate::messages::tool::common_functionality::shapes::grid_shape::Grid;
use crate::messages::tool::common_functionality::shapes::line_shape::{LineToolData, clicked_on_line_endpoints};
use crate::messages::tool::common_functionality::shapes::polygon_shape::Polygon;
use crate::messages::tool::common_functionality::shapes::shape_utility::{ShapeToolModifierKey, ShapeType, anchor_overlays, transform_cage_overlays};
use crate::messages::tool::common_functionality::shapes::spiral_shape::Spiral;
use crate::messages::tool::common_functionality::shapes::star_shape::Star;
use crate::messages::tool::common_functionality::shapes::{Ellipse, Line, Rectangle};
use crate::messages::tool::common_functionality::snapping::{self, SnapCandidatePoint, SnapData, SnapTypeConfiguration};
@ -22,7 +23,7 @@ use crate::messages::tool::common_functionality::utility_functions::{closest_poi
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::renderer::Quad;
use graphene_std::vector::misc::{ArcType, GridType};
use graphene_std::vector::misc::{ArcType, GridType, SpiralType};
use std::vec;
#[derive(Default, ExtractField)]
@ -40,6 +41,8 @@ pub struct ShapeToolOptions {
shape_type: ShapeType,
arc_type: ArcType,
grid_type: GridType,
spiral_type: SpiralType,
turns: f64,
}
impl Default for ShapeToolOptions {
@ -51,6 +54,8 @@ impl Default for ShapeToolOptions {
vertices: 5,
shape_type: ShapeType::Polygon,
arc_type: ArcType::Open,
spiral_type: SpiralType::Archimedean,
turns: 5.,
grid_type: GridType::Rectangular,
}
}
@ -67,6 +72,8 @@ pub enum ShapeOptionsUpdate {
Vertices(u32),
ShapeType(ShapeType),
ArcType(ArcType),
SpiralType(SpiralType),
Turns(f64),
GridType(GridType),
}
@ -109,6 +116,20 @@ fn create_sides_widget(vertices: u32) -> WidgetHolder {
.widget_holder()
}
fn create_turns_widget(turns: f64) -> WidgetHolder {
NumberInput::new(Some(turns))
.label("Turns")
.min(0.5)
.mode(NumberInputMode::Increment)
.on_update(|number_input: &NumberInput| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Turns(number_input.value.unwrap()),
}
.into()
})
.widget_holder()
}
fn create_shape_option_widget(shape_type: ShapeType) -> WidgetHolder {
let entries = vec![vec![
MenuListEntry::new("Polygon").label("Polygon").on_commit(move |_| {
@ -135,6 +156,12 @@ fn create_shape_option_widget(shape_type: ShapeType) -> WidgetHolder {
}
.into()
}),
MenuListEntry::new("Spiral").label("Spiral").on_commit(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ShapeType(ShapeType::Spiral),
}
.into()
}),
MenuListEntry::new("Grid").label("Grid").on_commit(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ShapeType(ShapeType::Grid),
@ -184,6 +211,24 @@ fn create_weight_widget(line_weight: f64) -> WidgetHolder {
.widget_holder()
}
fn create_spiral_type_widget(spiral_type: SpiralType) -> WidgetHolder {
let entries = vec![vec![
MenuListEntry::new("Archimedean").label("Archimedean").on_commit(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::SpiralType(SpiralType::Archimedean),
}
.into()
}),
MenuListEntry::new("Logarithmic").label("Logarithmic").on_commit(move |_| {
ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::SpiralType(SpiralType::Logarithmic),
}
.into()
}),
]];
DropdownInput::new(entries).selected_index(Some(spiral_type as u32)).widget_holder()
}
fn create_grid_type_widget(grid_type: GridType) -> WidgetHolder {
let entries = vec![
RadioEntryData::new("Rectangular").label("Rectangular").on_update(move |_| {
@ -221,6 +266,14 @@ impl LayoutHolder for ShapeTool {
}
}
if self.options.shape_type == ShapeType::Spiral {
widgets.push(create_spiral_type_widget(self.options.spiral_type));
widgets.push(Separator::new(SeparatorType::Related).widget_holder());
widgets.push(create_turns_widget(self.options.turns));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
}
if self.options.shape_type == ShapeType::Grid {
widgets.push(create_grid_type_widget(self.options.grid_type));
widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@ -327,6 +380,12 @@ impl<'a> MessageHandler<ToolMessage, &mut ToolActionMessageContext<'a>> for Shap
ShapeOptionsUpdate::ArcType(arc_type) => {
self.options.arc_type = arc_type;
}
ShapeOptionsUpdate::SpiralType(spiral_type) => {
self.options.spiral_type = spiral_type;
}
ShapeOptionsUpdate::Turns(turns) => {
self.options.turns = turns;
}
ShapeOptionsUpdate::GridType(grid_type) => {
self.options.grid_type = grid_type;
}
@ -471,6 +530,18 @@ impl ShapeToolData {
fn shape_tool_modifier_keys() -> [Key; 3] {
[Key::Alt, Key::Shift, Key::Control]
}
fn decrease_or_increase_sides(&self, document: &DocumentMessageHandler, shape_type: ShapeType, responses: &mut VecDeque<Message>, decrease: bool) {
if let Some(layer) = self.data.layer {
match shape_type {
ShapeType::Star | ShapeType::Polygon => Polygon::decrease_or_increase_sides(decrease, layer, document, responses),
ShapeType::Spiral => Spiral::update_turns(decrease, layer, document, responses),
_ => {}
}
}
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
impl Fsm for ShapeToolFsmState {
@ -584,15 +655,32 @@ impl Fsm for ShapeToolFsmState {
self
}
(ShapeToolFsmState::Ready(_), ShapeToolMessage::IncreaseSides) => {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices(tool_options.vertices + 1),
});
if matches!(tool_options.shape_type, ShapeType::Star | ShapeType::Polygon) {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices(tool_options.vertices + 1),
});
}
if matches!(tool_options.shape_type, ShapeType::Spiral) {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Turns(tool_options.turns + 1.),
});
}
self
}
(ShapeToolFsmState::Ready(_), ShapeToolMessage::DecreaseSides) => {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices((tool_options.vertices - 1).max(3)),
});
if matches!(tool_options.shape_type, ShapeType::Star | ShapeType::Polygon) {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Vertices((tool_options.vertices - 1).max(3)),
});
}
if matches!(tool_options.shape_type, ShapeType::Spiral) {
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::Turns((tool_options.turns - 1.).max(1.)),
});
}
self
}
(
@ -629,13 +717,11 @@ impl Fsm for ShapeToolFsmState {
self
}
(ShapeToolFsmState::Drawing(_), ShapeToolMessage::IncreaseSides) => {
Polygon::increase_decrease_sides(true, document, tool_data, responses);
tool_data.decrease_or_increase_sides(document, tool_options.shape_type, responses, false);
self
}
(ShapeToolFsmState::Drawing(_), ShapeToolMessage::DecreaseSides) => {
Polygon::increase_decrease_sides(false, document, tool_data, responses);
tool_data.decrease_or_increase_sides(document, tool_options.shape_type, responses, true);
self
}
(ShapeToolFsmState::Ready(_), ShapeToolMessage::DragStart) => {
@ -719,7 +805,9 @@ impl Fsm for ShapeToolFsmState {
};
match tool_data.current_shape {
ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => tool_data.data.start(document, input),
ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => {
tool_data.data.start(document, input)
}
ShapeType::Line => {
let point = SnapCandidatePoint::handle(document.metadata().document_to_viewport.inverse().transform_point2(input.mouse.position));
let snapped = tool_data.data.snap_manager.free_snap(&SnapData::new(document, input), &point, SnapTypeConfiguration::default());
@ -734,6 +822,7 @@ impl Fsm for ShapeToolFsmState {
ShapeType::Star => Star::create_node(tool_options.vertices),
ShapeType::Circle => Circle::create_node(),
ShapeType::Arc => Arc::create_node(tool_options.arc_type),
ShapeType::Spiral => Spiral::create_node(tool_options.spiral_type, tool_options.turns),
ShapeType::Grid => Grid::create_node(tool_options.grid_type),
ShapeType::Rectangle => Rectangle::create_node(),
ShapeType::Ellipse => Ellipse::create_node(),
@ -746,7 +835,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::Grid | ShapeType::Rectangle | ShapeType::Ellipse => {
ShapeType::Polygon | ShapeType::Star | ShapeType::Circle | ShapeType::Arc | ShapeType::Spiral | ShapeType::Grid | ShapeType::Rectangle | ShapeType::Ellipse => {
defered_responses.add(GraphOperationMessage::TransformSet {
layer,
transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position),
@ -783,6 +872,7 @@ impl Fsm for ShapeToolFsmState {
ShapeType::Star => Star::update_shape(document, input, layer, tool_data, modifier, responses),
ShapeType::Circle => Circle::update_shape(document, input, layer, tool_data, modifier, responses),
ShapeType::Arc => Arc::update_shape(document, input, layer, tool_data, modifier, responses),
ShapeType::Spiral => Spiral::update_shape(document, input, layer, tool_data, responses),
ShapeType::Grid => Grid::update_shape(document, input, layer, tool_options.grid_type, tool_data, modifier, responses),
ShapeType::Rectangle => Rectangle::update_shape(document, input, layer, tool_data, modifier, responses),
ShapeType::Ellipse => Ellipse::update_shape(document, input, layer, tool_data, modifier, responses),
@ -964,6 +1054,9 @@ impl Fsm for ShapeToolFsmState {
responses.add(DocumentMessage::AbortTransaction);
tool_data.data.cleanup(responses);
tool_data.current_shape = shape;
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ShapeType(shape),
});
responses.add(ShapeToolMessage::UpdateOptions {
options: ShapeOptionsUpdate::ShapeType(shape),
@ -1000,6 +1093,10 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque<Mess
]),
HintGroup(vec![HintInfo::multi_keys([[Key::BracketLeft], [Key::BracketRight]], "Decrease/Increase Sides")]),
],
ShapeType::Spiral => vec![
HintGroup(vec![HintInfo::mouse(MouseMotion::LmbDrag, "Draw Spiral")]),
HintGroup(vec![HintInfo::multi_keys([[Key::BracketLeft], [Key::BracketRight]], "Decrease/Increase Turns")]),
],
ShapeType::Ellipse => vec![HintGroup(vec![
HintInfo::mouse(MouseMotion::LmbDrag, "Draw Ellipse"),
HintInfo::keys([Key::Shift], "Constrain Circular").prepend_plus(),
@ -1046,6 +1143,7 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque<Mess
HintInfo::keys([Key::Control], "Lock Angle"),
]),
ShapeType::Circle => HintGroup(vec![HintInfo::keys([Key::Alt], "From Center")]),
ShapeType::Spiral => HintGroup(vec![]),
};
if !tool_hint_group.0.is_empty() {
@ -1056,6 +1154,10 @@ fn update_dynamic_hints(state: &ShapeToolFsmState, responses: &mut VecDeque<Mess
common_hint_group.push(HintGroup(vec![HintInfo::multi_keys([[Key::BracketLeft], [Key::BracketRight]], "Decrease/Increase Sides")]));
}
if matches!(shape, ShapeType::Spiral) {
common_hint_group.push(HintGroup(vec![HintInfo::multi_keys([[Key::BracketLeft], [Key::BracketRight]], "Decrease/Increase Turns")]));
}
HintData(common_hint_group)
}
ShapeToolFsmState::DraggingLineEndpoints => HintData(vec![

View file

@ -119,7 +119,7 @@ impl MessageHandler<TransformLayerMessage, TransformLayerMessageContext<'_>> for
return;
}
if !using_path_tool {
if !using_path_tool || !using_shape_tool {
self.pivot_gizmo.recalculate_transform(document);
*selected.pivot = self.pivot_gizmo.position(document);
self.local_pivot = document.metadata().document_to_viewport.inverse().transform_point2(*selected.pivot);

View file

@ -1,8 +1,9 @@
use super::consts::*;
use super::*;
use crate::vector::misc::point_to_dvec2;
use crate::vector::misc::{SpiralType, point_to_dvec2};
use glam::DVec2;
use kurbo::PathSeg;
use std::f64::consts::TAU;
pub struct PathSegPoints {
pub p0: DVec2,
@ -315,4 +316,125 @@ impl<PointId: Identifier> Subpath<PointId> {
pub fn new_line(p1: DVec2, p2: DVec2) -> Self {
Self::from_anchors([p1, p2], false)
}
pub fn new_spiral(a: f64, outer_radius: f64, turns: f64, start_angle: f64, delta_theta: f64, spiral_type: SpiralType) -> Self {
let mut manipulator_groups = Vec::new();
let mut prev_in_handle = None;
let theta_end = turns * std::f64::consts::TAU + start_angle;
let b = calculate_b(a, turns, outer_radius, spiral_type);
let mut theta = start_angle;
while theta < theta_end {
let theta_next = f64::min(theta + delta_theta, theta_end);
let p0 = spiral_point(theta, a, b, spiral_type);
let p3 = spiral_point(theta_next, a, b, spiral_type);
let t0 = spiral_tangent(theta, a, b, spiral_type);
let t1 = spiral_tangent(theta_next, a, b, spiral_type);
let arc_len = spiral_arc_length(theta, theta_next, a, b, spiral_type);
let d = arc_len / 3.;
let p1 = p0 + d * t0;
let p2 = p3 - d * t1;
manipulator_groups.push(ManipulatorGroup::new(p0, prev_in_handle, Some(p1)));
prev_in_handle = Some(p2);
// If final segment, end with anchor at theta_end
if (theta_next - theta_end).abs() < f64::EPSILON {
manipulator_groups.push(ManipulatorGroup::new(p3, prev_in_handle, None));
break;
}
theta = theta_next;
}
Self::new(manipulator_groups, false)
}
}
pub fn calculate_b(a: f64, turns: f64, outer_radius: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => {
let total_theta = turns * TAU;
(outer_radius - a) / total_theta
}
SpiralType::Logarithmic => {
let total_theta = turns * TAU;
((outer_radius.abs() / a).ln()) / total_theta
}
}
}
/// Returns a point on the given spiral type at angle `theta`.
pub fn spiral_point(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_point(theta, a, b),
SpiralType::Logarithmic => log_spiral_point(theta, a, b),
}
}
/// Returns the tangent direction at angle `theta` for the given spiral type.
pub fn spiral_tangent(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_tangent(theta, a, b),
SpiralType::Logarithmic => log_spiral_tangent(theta, a, b),
}
}
/// Computes arc length between two angles for the given spiral type.
pub fn spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_arc_length(theta_start, theta_end, a, b),
SpiralType::Logarithmic => log_spiral_arc_length(theta_start, theta_end, a, b),
}
}
/// Returns a point on a logarithmic spiral at angle `theta`.
pub fn log_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp(); // a * e^(bθ)
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Computes arc length along a logarithmic spiral between two angles.
pub fn log_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
let factor = (1. + b * b).sqrt();
(a / b) * factor * ((b * theta_end).exp() - (b * theta_start).exp())
}
/// Returns the tangent direction of a logarithmic spiral at angle `theta`.
pub fn log_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp();
let dx = r * (b * theta.cos() - theta.sin());
let dy = r * (b * theta.sin() + theta.cos());
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Returns a point on an Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Returns the tangent direction of an Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
let dx = b * theta.cos() - r * theta.sin();
let dy = b * theta.sin() + r * theta.cos();
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Computes arc length along an Archimedean spiral between two angles.
pub fn archimedean_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
archimedean_spiral_arc_length_origin(theta_end, a, b) - archimedean_spiral_arc_length_origin(theta_start, a, b)
}
/// Computes arc length from origin to a point on Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_arc_length_origin(theta: f64, a: f64, b: f64) -> f64 {
let r = a + b * theta;
let sqrt_term = (r * r + b * b).sqrt();
(r * sqrt_term + b * b * ((r + sqrt_term).ln())) / (2. * b)
}

View file

@ -5,7 +5,7 @@ use crate::registry::types::{Angle, PixelSize};
use crate::subpath;
use crate::table::Table;
use crate::vector::Vector;
use crate::vector::misc::HandleId;
use crate::vector::misc::{HandleId, SpiralType};
use glam::DVec2;
trait CornerRadius {
@ -75,6 +75,27 @@ fn arc(
)))
}
#[node_macro::node(category("Vector: Shape"), properties("spiral_properties"))]
fn spiral(
_: impl Ctx,
_primary: (),
spiral_type: SpiralType,
#[default(5.)] turns: f64,
#[default(0.)] start_angle: f64,
#[default(0.)] inner_radius: f64,
#[default(25)] outer_radius: f64,
#[default(90.)] angular_resolution: f64,
) -> Table<Vector> {
Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_spiral(
inner_radius,
outer_radius,
turns,
start_angle.to_radians(),
angular_resolution.to_radians(),
spiral_type,
)))
}
#[node_macro::node(category("Vector: Shape"))]
fn ellipse(
_: impl Ctx,

View file

@ -415,3 +415,11 @@ impl HandleId {
}
}
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Dropdown)]
pub enum SpiralType {
#[default]
Archimedean,
Logarithmic,
}

View file

@ -241,9 +241,8 @@ async fn repeat<I: 'n + Send + Clone>(
#[node_macro::node(category("Instancing"), path(graphene_core::vector))]
async fn circular_repeat<I: 'n + Send + Clone>(
_: impl Ctx,
// TODO: Implement other graphical types.
#[implementations(Table<Graphic>, Table<Vector>, Table<Raster<CPU>>, Table<Color>, Table<GradientStops>)] instance: Table<I>,
angle_offset: Angle,
start_angle: Angle,
#[unit(" px")]
#[default(5)]
radius: f64,
@ -254,7 +253,7 @@ async fn circular_repeat<I: 'n + Send + Clone>(
let mut result_table = Table::new();
for index in 0..count {
let angle = DAffine2::from_angle((TAU / count as f64) * index as f64 + angle_offset.to_radians());
let angle = DAffine2::from_angle((TAU / count as f64) * index as f64 + start_angle.to_radians());
let translation = DAffine2::from_translation(radius * DVec2::Y);
let transform = angle * translation;

View file

@ -242,6 +242,7 @@ tagged_value! {
ArcType(graphene_core::vector::misc::ArcType),
MergeByDistanceAlgorithm(graphene_core::vector::misc::MergeByDistanceAlgorithm),
PointSpacingType(graphene_core::vector::misc::PointSpacingType),
SpiralType(graphene_core::vector::misc::SpiralType),
#[serde(alias = "LineCap")]
StrokeCap(graphene_core::vector::style::StrokeCap),
#[serde(alias = "LineJoin")]

View file

@ -49,6 +49,7 @@ Next, that is fed into the <img src="https://static.graphite.rs/content/learn/in
| <img src="https://static.graphite.rs/content/learn/introduction/features-and-limitations/circular-repeat-node-parameters-3__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="" /> | <img src="https://static.graphite.rs/content/learn/introduction/features-and-limitations/circular-repeat-node-output-3.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="" /> |
| <img src="https://static.graphite.rs/content/learn/introduction/features-and-limitations/circular-repeat-node-parameters-4__2.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="" /> | <img src="https://static.graphite.rs/content/learn/introduction/features-and-limitations/circular-repeat-node-output-4.avif" onerror="this.onerror = null; this.src = this.src.replace('.avif', '.png')" onload="this.width = this.naturalWidth / 2" alt="" /> |
<!-- TODO: Rename "Angle Offset" to "Start Angle" and redo the screenshots which show that -->
The node's properties offer controls over settings like *Angle Offset* (what angle to start at), *Radius* (distance from the center), and *Instances* (how many copies to distribute). These parameters can also be exposed into the graph so they are driven by the calculated numerical outputs of other nodes instead of values you pick by hand.
### Raster compositing