diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index beda822377..c50a5095c8 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -2462,6 +2462,7 @@ fn static_nodes() -> Vec { ("graphene_core::raster::adjustments::ChannelMixerNode", &node_properties::channel_mixer_properties as PropertiesLayout), ("graphene_core::vector::FillNode", &node_properties::fill_properties as PropertiesLayout), ("graphene_core::vector::StrokeNode", &node_properties::stroke_properties as PropertiesLayout), + ("graphene_core::vector::OffsetPathNode", &node_properties::offset_path_properties as PropertiesLayout), ( "graphene_core::raster::adjustments::SelectiveColorNode", &node_properties::selective_color_properties as PropertiesLayout, diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 080402625e..cc4ca14280 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -115,12 +115,12 @@ pub(crate) fn property_from_type( Some("Percentage") => number_widget(document_node, node_id, index, name, number_input.percentage().min(min(0.)).max(max(100.)), true).into(), Some("SignedPercentage") => number_widget(document_node, node_id, index, name, number_input.percentage().min(min(-100.)).max(max(100.)), true).into(), Some("Angle") => number_widget(document_node, node_id, index, name, number_input.mode_range().min(min(-180.)).max(max(180.)).unit("°"), true).into(), - Some("PixelLength") => number_widget(document_node, node_id, index, name, number_input.min(min(0.)).unit("px"), true).into(), + Some("PixelLength") => number_widget(document_node, node_id, index, name, number_input.min(min(0.)).unit(" px"), true).into(), Some("Length") => number_widget(document_node, node_id, index, name, number_input.min(min(0.)), true).into(), Some("Fraction") => number_widget(document_node, node_id, index, name, number_input.min(min(0.)).max(max(1.)), true).into(), Some("IntegerCount") => number_widget(document_node, node_id, index, name, number_input.int().min(min(1.)), true).into(), Some("SeedValue") => number_widget(document_node, node_id, index, name, number_input.int().min(min(0.)), true).into(), - Some("Resolution") => vec2_widget(document_node, node_id, index, name, "W", "H", "px", Some(64.), add_blank_assist), + Some("Resolution") => vec2_widget(document_node, node_id, index, name, "W", "H", " px", Some(64.), add_blank_assist), // For all other types, use TypeId-based matching _ => { @@ -1681,9 +1681,10 @@ pub(crate) fn rectangle_properties(document_node: &DocumentNode, node_id: NodeId } pub(crate) fn line_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { - let operand = |name: &str, index| vec2_widget(document_node, node_id, index, name, "X", "Y", "px", None, add_blank_assist); + let operand = |name: &str, index| vec2_widget(document_node, node_id, index, name, "X", "Y", " px", None, add_blank_assist); vec![operand("Start", 1), operand("End", 2)] } + pub(crate) fn spline_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { vec![LayoutGroup::Row { widgets: vec_dvec2_input(document_node, node_id, 1, "Points", TextInput::default().centered(true), true), @@ -2555,14 +2556,14 @@ pub fn stroke_properties(document_node: &DocumentNode, node_id: NodeId, _context let miter_limit_index = 7; let color = color_widget(document_node, node_id, color_index, "Color", ColorButton::default(), true); - let weight = number_widget(document_node, node_id, weight_index, "Weight", NumberInput::default().unit("px").min(0.), true); + let weight = number_widget(document_node, node_id, weight_index, "Weight", NumberInput::default().unit(" px").min(0.), true); let dash_lengths_val = match &document_node.inputs[dash_lengths_index].as_value() { Some(TaggedValue::VecF64(x)) => x, _ => &vec![], }; let dash_lengths = vec_f64_input(document_node, node_id, dash_lengths_index, "Dash Lengths", TextInput::default().centered(true), true); - let number_input = NumberInput::default().unit("px").disabled(dash_lengths_val.is_empty()); + let number_input = NumberInput::default().unit(" px").disabled(dash_lengths_val.is_empty()); let dash_offset = number_widget(document_node, node_id, dash_offset_index, "Dash Offset", number_input, true); let line_cap = line_cap_widget(document_node, node_id, line_cap_index, "Line Cap", true); let line_join = line_join_widget(document_node, node_id, line_join_index, "Line Join", true); @@ -2584,6 +2585,26 @@ pub fn stroke_properties(document_node: &DocumentNode, node_id: NodeId, _context ] } +pub fn offset_path_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { + let distance_index = 1; + let line_join_index = 2; + let miter_limit_index = 3; + + let number_input = NumberInput::default().unit(" px"); + let distance = number_widget(document_node, node_id, distance_index, "Offset", number_input, true); + + let line_join = line_join_widget(document_node, node_id, line_join_index, "Line Join", true); + let line_join_val = match &document_node.inputs[line_join_index].as_value() { + Some(TaggedValue::LineJoin(x)) => x, + _ => &LineJoin::Miter, + }; + + let number_input = NumberInput::default().min(0.).disabled(line_join_val != &LineJoin::Miter); + let miter_limit = number_widget(document_node, node_id, miter_limit_index, "Miter Limit", number_input, true); + + vec![LayoutGroup::Row { widgets: distance }, line_join, LayoutGroup::Row { widgets: miter_limit }] +} + pub(crate) fn artboard_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec { let location = vec2_widget(document_node, node_id, 2, "Location", "X", "Y", " px", None, add_blank_assist); let dimensions = vec2_widget(document_node, node_id, 3, "Dimensions", "W", "H", " px", None, add_blank_assist); diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte index a19a9eab02..44f719aeef 100644 --- a/frontend/src/components/floating-menus/ColorPicker.svelte +++ b/frontend/src/components/floating-menus/ColorPicker.svelte @@ -3,7 +3,7 @@ import { clamp } from "@graphite/utility-functions/math"; import type { Editor } from "@graphite/wasm-communication/editor"; - import { type HSV, type RGB, type FillChoice } from "@graphite/wasm-communication/messages"; + import type { HSV, RGB, FillChoice } from "@graphite/wasm-communication/messages"; import { Color, Gradient } from "@graphite/wasm-communication/messages"; import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte"; diff --git a/libraries/bezier-rs/src/subpath/solvers.rs b/libraries/bezier-rs/src/subpath/solvers.rs index 321f3858ac..0c9ab58bb9 100644 --- a/libraries/bezier-rs/src/subpath/solvers.rs +++ b/libraries/bezier-rs/src/subpath/solvers.rs @@ -380,17 +380,24 @@ impl Subpath { /// /// Alternatively, this can be interpreted as limiting the angle that the miter can form. /// When the limit is exceeded, no manipulator group will be returned. - /// This value should be at least 1. If not, the default of 4 will be used. + /// This value should be greater than 0. If not, the default of 4 will be used. pub(crate) fn miter_line_join(&self, other: &Subpath, miter_limit: Option) -> Option> { let miter_limit = match miter_limit { - Some(miter_limit) if miter_limit >= 1. => miter_limit, + Some(miter_limit) if miter_limit > f64::EPSILON => miter_limit, _ => 4., }; - let in_segment = self.get_segment(self.len_segments() - 1).unwrap(); - let out_segment = other.get_segment(0).unwrap(); + // TODO: Besides returning None using the `?` operator, is there a more appropriate way to handle a `None` result from `get_segment`? + let in_segment = self.get_segment(self.len_segments() - 1)?; + let out_segment = other.get_segment(0)?; + let in_tangent = in_segment.tangent(TValue::Parametric(1.)); let out_tangent = out_segment.tangent(TValue::Parametric(0.)); + if in_tangent == DVec2::ZERO || out_tangent == DVec2::ZERO { + // Avoid panic from normalizing zero vectors + // TODO: Besides returning None, is there a more appropriate way to handle this? + return None; + } let normalized_in_tangent = in_tangent.normalize(); let normalized_out_tangent = out_tangent.normalize(); @@ -400,11 +407,16 @@ impl Subpath { let start_to_intersection = intersection - in_segment.end(); let intersection_to_end = out_segment.start() - intersection; + if start_to_intersection == DVec2::ZERO || intersection_to_end == DVec2::ZERO { + // Avoid panic from normalizing zero vectors + // TODO: Besides returning None, is there a more appropriate way to handle this? + return None; + } // Draw the miter join if the intersection occurs in the correct direction with respect to the path if start_to_intersection.normalize().abs_diff_eq(in_tangent, MAX_ABSOLUTE_DIFFERENCE) && intersection_to_end.normalize().abs_diff_eq(out_tangent, MAX_ABSOLUTE_DIFFERENCE) - && miter_limit >= 1. / (start_to_intersection.angle_to(-intersection_to_end).abs() / 2.).sin() + && miter_limit > f64::EPSILON / (start_to_intersection.angle_to(-intersection_to_end).abs() / 2.).sin() { return Some(ManipulatorGroup { anchor: intersection, diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index cf08168291..440edda3cb 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -4,6 +4,7 @@ use super::{PointId, SegmentId, StrokeId, VectorData}; use crate::registry::types::{Angle, Fraction, IntegerCount, Length, SeedValue}; use crate::renderer::GraphicElementRendered; use crate::transform::{Footprint, Transform, TransformMut}; +use crate::vector::style::LineJoin; use crate::{Color, GraphicGroup}; use bezier_rs::{Cap, Join, Subpath, SubpathTValue, TValue}; @@ -60,7 +61,7 @@ async fn assign_colors( let factor = match randomize { true => rng.gen::(), false => match repeat_every { - 0 => i as f64 / (length - 1) as f64, + 0 => i as f64 / (length - 1).max(1) as f64, 1 => 0., _ => i as f64 % repeat_every as f64 / (repeat_every - 1) as f64, }, @@ -145,7 +146,7 @@ async fn stroke> + 'n + Send>( dash_lengths: Vec, dash_offset: f64, line_cap: crate::vector::style::LineCap, - line_join: crate::vector::style::LineJoin, + line_join: LineJoin, #[default(4.)] miter_limit: f64, ) -> VectorData { let mut vector_data = vector_data.eval(footprint).await; @@ -277,6 +278,50 @@ async fn bounding_box( result } +#[node_macro::node(category("Vector"), path(graphene_core::vector))] +async fn offset_path( + #[implementations( + (), + Footprint, + )] + footprint: F, + #[implementations( + () -> VectorData, + Footprint -> VectorData, + )] + vector_data: impl Node, + distance: f64, + line_join: LineJoin, + #[default(4.)] miter_limit: f64, +) -> VectorData { + let vector_data = vector_data.eval(footprint).await; + + let subpaths = vector_data.stroke_bezier_paths(); + let mut result = VectorData::empty(); + result.style = vector_data.style.clone(); + result.style.set_stroke_transform(DAffine2::IDENTITY); + + // Perform operation on all subpaths in this shape. + for mut subpath in subpaths { + subpath.apply_transform(vector_data.transform); + + // Taking the existing stroke data and passing it to Bezier-rs to generate new paths. + let subpath_out = subpath.offset( + -distance, + match line_join { + LineJoin::Miter => Join::Miter(Some(miter_limit)), + LineJoin::Bevel => Join::Bevel, + LineJoin::Round => Join::Round, + }, + ); + + // One closed subpath, open path. + result.append_subpath(subpath_out, false); + } + + result +} + #[node_macro::node(category("Vector"), path(graphene_core::vector))] async fn solidify_stroke( #[implementations( @@ -305,9 +350,9 @@ async fn solidify_stroke( let subpath_out = subpath.outline( stroke.weight / 2., // Diameter to radius. match stroke.line_join { - crate::vector::style::LineJoin::Miter => Join::Miter(Some(stroke.line_join_miter_limit)), - crate::vector::style::LineJoin::Bevel => Join::Bevel, - crate::vector::style::LineJoin::Round => Join::Round, + LineJoin::Miter => Join::Miter(Some(stroke.line_join_miter_limit)), + LineJoin::Bevel => Join::Bevel, + LineJoin::Round => Join::Round, }, match stroke.line_cap { crate::vector::style::LineCap::Butt => Cap::Butt,