diff --git a/Cargo.toml b/Cargo.toml index 1740899a46..8a9dfabc8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,8 @@ web-sys = { version = "=0.3.77", features = [ "HtmlCanvasElement", "CanvasRenderingContext2d", "CanvasPattern", + "DomMatrix", + "SvgMatrix", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "TextMetrics", diff --git a/editor/src/consts.rs b/editor/src/consts.rs index bee85d36e1..59e78d6c79 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -102,6 +102,9 @@ pub const MAXIMUM_ALT_SCALE_FACTOR: f64 = 25.; /// The width or height that the transform cage needs before it is considered to have no width or height. pub const MAX_LENGTH_FOR_NO_WIDTH_OR_HEIGHT: f64 = 1e-4; +// BOUNDING BOX +pub const INFLATE_FACTOR: f64 = 1.5; + // SKEW TRIANGLES pub const SKEW_TRIANGLE_SIZE: f64 = 7.; pub const SKEW_TRIANGLE_OFFSET: f64 = 4.; diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 90a0720eca..79b870679a 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1122,6 +1122,7 @@ impl MessageHandler> for DocumentMes responses.add(PortfolioMessage::UpdateDocumentWidgets); } OverlaysType::Handles => visibility_settings.handles = visible, + OverlaysType::FillableIndicator => visibility_settings.fillable_indicator = visible, } responses.add(EventMessage::ToolAbort); @@ -2801,6 +2802,23 @@ impl DocumentMessageHandler { .widget_instance(), ] }), + LayoutGroup::row(vec![TextLabel::new("Fill Tool").widget_instance()]), + LayoutGroup::row({ + let checkbox_id = CheckboxId::new(); + vec![ + CheckboxInput::new(self.overlays_visibility_settings.fillable_indicator) + .on_update(|optional_input: &CheckboxInput| { + DocumentMessage::SetOverlaysVisibility { + visible: optional_input.checked, + overlays_type: Some(OverlaysType::FillableIndicator), + } + .into() + }) + .for_label(checkbox_id) + .widget_instance(), + TextLabel::new("Fillable Indicator".to_string()).for_checkbox(checkbox_id).widget_instance(), + ] + }), ])) .widget_instance(), Separator::new(SeparatorStyle::Related).widget_instance(), diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 3ce54d16b1..aa6651ebae 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -56,6 +56,10 @@ pub enum GraphOperationMessage { layer: LayerNodeIdentifier, stroke: Stroke, }, + StrokeColorSet { + layer: LayerNodeIdentifier, + stroke_color: Color, + }, TransformChange { layer: LayerNodeIdentifier, transform: DAffine2, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 1aaa771fa7..d5b6e96515 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -86,6 +86,11 @@ impl MessageHandler> for modify_inputs.stroke_set(stroke); } } + GraphOperationMessage::StrokeColorSet { layer, stroke_color } => { + if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { + modify_inputs.stroke_color_set(Some(stroke_color)); + } + } GraphOperationMessage::TransformChange { layer, transform, diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index cb7f9618a1..7568b20f70 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -618,6 +618,15 @@ impl<'a> ModifyInputsContext<'a> { self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.dash_offset), false), true); } + pub fn stroke_color_set(&mut self, color: Option) { + let Some(stroke_node_id) = self.existing_proto_node_id(graphene_std::vector::stroke::IDENTIFIER, false) else { + return; + }; + + let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::ColorInput::INDEX); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(color), false), false); + } + /// Update the transform value of the upstream Transform node based a change to its existing value and the given parent transform. /// A new Transform node is created if one does not exist, unless it would be given the identity transform. pub fn transform_change_with_parent(&mut self, transform: DAffine2, transform_in: TransformIn, parent_transform: DAffine2, skip_rerender: bool) { diff --git a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs index c7acb50c59..83bc915b9c 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -56,13 +56,6 @@ impl MessageHandler> for OverlaysMes canvas_context.clear_rect(0., 0., width, height); if visibility_settings.all() { - responses.add(DocumentMessage::GridOverlays { - context: OverlayContext { - render_context: canvas_context.clone(), - visibility_settings: visibility_settings.clone(), - viewport: *viewport, - }, - }); for provider in &self.overlay_providers { responses.add(provider(OverlayContext { render_context: canvas_context.clone(), @@ -70,6 +63,13 @@ impl MessageHandler> for OverlaysMes viewport: *viewport, })); } + responses.add(DocumentMessage::GridOverlays { + context: OverlayContext { + render_context: canvas_context.clone(), + visibility_settings: visibility_settings.clone(), + viewport: *viewport, + }, + }); } } #[cfg(all(not(target_family = "wasm"), not(test)))] @@ -79,11 +79,10 @@ impl MessageHandler> for OverlaysMes let overlay_context = OverlayContext::new(*viewport, visibility_settings); if visibility_settings.all() { - responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); - for provider in &self.overlay_providers { responses.add(provider(overlay_context.clone())); } + responses.add(DocumentMessage::GridOverlays { context: overlay_context.clone() }); } responses.add(FrontendMessage::RenderOverlays { context: overlay_context }); } diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs index acda35d859..7302b6b07b 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -1,7 +1,8 @@ use crate::consts::{ ARC_SWEEP_GIZMO_RADIUS, COLOR_OVERLAY_BLACK, COLOR_OVERLAY_BLUE, COLOR_OVERLAY_BLUE_50, COLOR_OVERLAY_GREEN, COLOR_OVERLAY_RED, COLOR_OVERLAY_WHITE, COLOR_OVERLAY_WHITE_05, COLOR_OVERLAY_YELLOW, COLOR_OVERLAY_YELLOW_DULL, COMPASS_ROSE_ARROW_SIZE, COMPASS_ROSE_HOVER_RING_DIAMETER, COMPASS_ROSE_MAIN_RING_DIAMETER, COMPASS_ROSE_RING_INNER_DIAMETER, DOWEL_PIN_RADIUS, - GRADIENT_MIDPOINT_DIAMOND_RADIUS, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, SKEW_TRIANGLE_SIZE, + GRADIENT_MIDPOINT_DIAMOND_RADIUS, INFLATE_FACTOR, MANIPULATOR_GROUP_MARKER_SIZE, PIVOT_CROSSHAIR_LENGTH, PIVOT_CROSSHAIR_THICKNESS, PIVOT_DIAMETER, RESIZE_HANDLE_SIZE, SKEW_TRIANGLE_OFFSET, + SKEW_TRIANGLE_SIZE, }; use crate::messages::portfolio::document::overlays::utility_functions::{GLOBAL_FONT_CACHE, GLOBAL_TEXT_CONTEXT, hex_to_rgba_u8}; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; @@ -17,13 +18,14 @@ use graphene_std::subpath::{self, Subpath}; use graphene_std::text::{Font, TextAlign, TypesettingConfig}; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::point_to_dvec2; +use graphene_std::vector::style::{PaintOrder, Stroke, StrokeAlign}; use graphene_std::vector::{PointId, SegmentId, Vector}; -use kurbo::{self, BezPath, ParamCurve}; +use kurbo::{self, BezPath, ParamCurve, Shape}; use kurbo::{Affine, PathSeg}; use std::collections::HashMap; use std::sync::{Arc, Mutex, MutexGuard}; use vello::Scene; -use vello::peniko; +use vello::peniko::{self, BlendMode}; // TODO Remove duplicated definition of this in `utility_types_web.rs` pub type OverlayProvider = fn(OverlayContext) -> Message; @@ -46,19 +48,32 @@ pub enum GizmoEmphasis { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum OverlaysType { + // ======= + // General + // ======= ArtboardName, - CompassRose, - QuickMeasurement, TransformMeasurement, + // =========== + // Select Tool + // =========== + QuickMeasurement, TransformCage, + CompassRose, + Pivot, + Origin, HoverOutline, SelectionOutline, LayerOriginCross, - Pivot, - Origin, + // ================ + // Pen & Path Tools + // ================ Path, Anchors, Handles, + // ========= + // Fill Tool + // ========= + FillableIndicator, } // TODO Remove duplicated definition of this in `utility_types_web.rs` @@ -68,18 +83,19 @@ pub enum OverlaysType { pub struct OverlaysVisibilitySettings { pub all: bool, pub artboard_name: bool, - pub compass_rose: bool, - pub quick_measurement: bool, pub transform_measurement: bool, + pub quick_measurement: bool, pub transform_cage: bool, + pub compass_rose: bool, + pub pivot: bool, + pub origin: bool, pub hover_outline: bool, pub selection_outline: bool, pub layer_origin_cross: bool, - pub pivot: bool, - pub origin: bool, pub path: bool, pub anchors: bool, pub handles: bool, + pub fillable_indicator: bool, } // TODO Remove duplicated definition of this in `utility_types_web.rs` @@ -88,18 +104,19 @@ impl Default for OverlaysVisibilitySettings { Self { all: true, artboard_name: true, - compass_rose: true, - quick_measurement: true, transform_measurement: true, + quick_measurement: true, transform_cage: true, + compass_rose: true, + pivot: true, + origin: true, hover_outline: true, selection_outline: true, layer_origin_cross: true, - pivot: true, - origin: true, path: true, anchors: true, handles: true, + fillable_indicator: true, } } } @@ -161,6 +178,10 @@ impl OverlaysVisibilitySettings { pub fn handles(&self) -> bool { self.all && self.anchors && self.handles } + + pub fn fillable_indicator(&self) -> bool { + self.all && self.fillable_indicator + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -406,10 +427,16 @@ impl OverlayContext { self.internal().fill_path(subpaths, transform, color); } - /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is an sRGB hex string. /// Used by the fill tool to show the area to be filled. - pub fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - self.internal().fill_path_pattern(subpaths, transform, color); + pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + self.internal().fill_overlay(subpaths, is_closed_on_all, transform, color, stroke); + } + + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is an sRGB hex string. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + self.internal().stroke_overlay(subpaths, is_closed_on_all, transform, color, stroke); } pub fn text(&self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { @@ -976,7 +1003,7 @@ impl OverlayContextInternal { path.push(bezier.as_path_el()); } - fn push_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2) -> BezPath { + fn path_from_subpaths(&mut self, subpaths: impl Iterator>>, close_path: bool, transform: DAffine2) -> BezPath { let mut path = BezPath::new(); for subpath in subpaths { @@ -1017,7 +1044,7 @@ impl OverlayContextInternal { } } - if subpath.closed() { + if subpath.closed() && close_path { path.close_path(); } } @@ -1040,24 +1067,14 @@ impl OverlayContextInternal { } if !subpaths.is_empty() { - let path = self.push_path(subpaths.iter(), transform); + let path = self.path_from_subpaths(subpaths.iter(), true, transform); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.scene.stroke(&kurbo::Stroke::new(1.), self.get_transform(), Self::parse_color(color), None, &path); } } - /// Fills the area inside the path. Assumes `color` is in gamma space. - /// Used by the Pen tool to show the path being closed. - fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - let path = self.push_path(subpaths, transform); - - self.scene.fill(peniko::Fill::NonZero, self.get_transform(), Self::parse_color(color), None, &path); - } - - /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. - /// Used by the fill tool to show the area to be filled. - fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + pub fn fill_canvas_pattern_image(&self, color: &str) -> peniko::ImageBrush { const PATTERN_WIDTH: u32 = 4; const PATTERN_HEIGHT: u32 = 4; @@ -1094,10 +1111,128 @@ impl OverlayContextInternal { }, }; - let path = self.push_path(subpaths, transform); - let brush = peniko::Brush::Image(image); + image + } + + /// Fills the area inside the path. Assumes `color` is in gamma space. + /// Used by the Pen tool to show the path being closed. + fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + let path = self.path_from_subpaths(subpaths, true, transform); + + self.scene.fill(peniko::Fill::NonZero, self.get_transform(), Self::parse_color(color), None, &path); + } + + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is an sRGB hex string. + /// Used by the fill tool to show the area to be filled. + fn fill_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + if let Some(stroke) = stroke { + let has_real_stroke = stroke.weight() > 0. && stroke.transform.matrix2.determinant() != 0.; + let applied_stroke_transform = if has_real_stroke { stroke.transform } else { transform }; + let element_transform = if has_real_stroke { transform * stroke.transform.inverse() } else { DAffine2::IDENTITY }; + + // TODO: mitigate stroke artifacts when strokes are rendered for closed paths. + let path = self.path_from_subpaths(subpaths, false, applied_stroke_transform); + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(color)); + + let do_fill = |scene: &mut Scene| { + let element_transform = Affine::new(element_transform.to_cols_array()); + scene.fill(peniko::Fill::NonZero, element_transform, &brush, Some(element_transform.inverse()), &path); + }; + let composite_stroke_out = |scene: &mut Scene, compose_mode: peniko::Compose, stroke_scale: Option| { + let element_transform = Affine::new(element_transform.to_cols_array()); + let stroke = stroke.clone().with_weight(stroke.weight() * stroke_scale.unwrap_or(1.0)); + // TODO: find a method to rid of the extra offset factor + let inflation = stroke.weight() * INFLATE_FACTOR; + let path_bbox = path.bounding_box().inflate(inflation, inflation); + + scene.push_layer(peniko::Fill::NonZero, BlendMode::new(peniko::Mix::Normal, compose_mode), 1.0, element_transform, &path_bbox); + scene.stroke(&stroke.to_kurbo(), element_transform, &peniko::Brush::Solid(peniko::Color::BLACK), None, &path); + scene.pop_layer(); + }; - self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path); + // For layers with open subpaths, stroke align is ignored and set to default + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + do_fill(&mut self.scene); + composite_stroke_out(&mut self.scene, peniko::Compose::DestOut, Some(2.0)); + } + (StrokeAlign::Inside, PaintOrder::StrokeBelow) => { + do_fill(&mut self.scene); + } + (StrokeAlign::Center, PaintOrder::StrokeAbove) => { + do_fill(&mut self.scene); + composite_stroke_out(&mut self.scene, peniko::Compose::DestOut, None); + } + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + do_fill(&mut self.scene); + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + do_fill(&mut self.scene); + } + } + } else { + let path = self.path_from_subpaths(subpaths, false, transform); + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(color)); + self.scene.fill(peniko::Fill::NonZero, Affine::IDENTITY, &brush, None, &path); + } + } + + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is an sRGB hex string. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + if let Some(stroke) = stroke { + let has_real_stroke = stroke.weight() > 0. && stroke.transform.matrix2.determinant() != 0.; + let applied_stroke_transform = if has_real_stroke { stroke.transform } else { transform }; + let element_transform = if has_real_stroke { transform * stroke.transform.inverse() } else { DAffine2::IDENTITY }; + + // TODO: mitigate stroke artifacts when strokes are rendered for closed paths. + let path = self.path_from_subpaths(subpaths, false, applied_stroke_transform); + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(color)); + + let do_stroke = |scene: &mut Scene, stroke_scale: Option| { + let element_transform = Affine::new(element_transform.to_cols_array()); + let stroke = stroke.clone().with_weight(stroke.weight() * stroke_scale.unwrap_or(1.0)).to_kurbo(); + + scene.stroke(&stroke, element_transform, &brush, Some(element_transform.inverse()), &path); + }; + let composite_fill_out = |scene: &mut Scene, compose_mode: peniko::Compose, stroke_scale: Option| { + let element_transform = Affine::new(element_transform.to_cols_array()); + let stroke = stroke.clone().with_weight(stroke.weight() * stroke_scale.unwrap_or(1.0)); + // TODO: find a method to rid of the extra offset factor + let inflation = stroke.weight() * INFLATE_FACTOR; + let path_bbox = path.bounding_box().inflate(inflation, inflation); + + scene.push_layer(peniko::Fill::NonZero, BlendMode::new(peniko::Mix::Normal, compose_mode), 1.0, element_transform, &path_bbox); + scene.fill(peniko::Fill::NonZero, element_transform, &peniko::Brush::Solid(peniko::Color::BLACK), None, &path); + scene.pop_layer(); + }; + + // For layers with open subpaths, stroke align is ignored and set to default + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + do_stroke(&mut self.scene, Some(2.0)); + composite_fill_out(&mut self.scene, peniko::Compose::DestIn, Some(2.0)); + } + (StrokeAlign::Inside, PaintOrder::StrokeBelow) => {} + (StrokeAlign::Center, PaintOrder::StrokeAbove) => { + do_stroke(&mut self.scene, None); + } + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + do_stroke(&mut self.scene, None); + composite_fill_out(&mut self.scene, peniko::Compose::DestOut, None); + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + do_stroke(&mut self.scene, Some(2.0)); + composite_fill_out(&mut self.scene, peniko::Compose::DestOut, Some(2.0)); + } + } + } } fn text(&mut self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { diff --git a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs index 35538092c9..8c81f23fa4 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -15,11 +15,13 @@ use graphene_std::math::quad::Quad; use graphene_std::subpath::Subpath; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; +use graphene_std::vector::style::{PaintOrder, Stroke, StrokeAlign}; use graphene_std::vector::{PointId, SegmentId, Vector}; +use js_sys::{Array, Reflect}; use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; use std::collections::HashMap; use wasm_bindgen::{JsCast, JsValue}; -use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; +use web_sys::{CanvasPattern, DomMatrix, OffscreenCanvas, OffscreenCanvasRenderingContext2d}; pub type OverlayProvider = fn(OverlayContext) -> Message; @@ -38,19 +40,32 @@ pub enum GizmoEmphasis { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] pub enum OverlaysType { + // ======= + // General + // ======= ArtboardName, - CompassRose, - QuickMeasurement, TransformMeasurement, + // =========== + // Select Tool + // =========== + QuickMeasurement, TransformCage, + CompassRose, + Pivot, + Origin, HoverOutline, SelectionOutline, LayerOriginCross, - Pivot, - Origin, + // ================ + // Pen & Path Tools + // ================ Path, Anchors, Handles, + // ========= + // Fill Tool + // ========= + FillableIndicator, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -59,18 +74,19 @@ pub enum OverlaysType { pub struct OverlaysVisibilitySettings { pub all: bool, pub artboard_name: bool, - pub compass_rose: bool, - pub quick_measurement: bool, pub transform_measurement: bool, + pub quick_measurement: bool, pub transform_cage: bool, + pub compass_rose: bool, + pub pivot: bool, + pub origin: bool, pub hover_outline: bool, pub selection_outline: bool, pub layer_origin_cross: bool, - pub pivot: bool, - pub origin: bool, pub path: bool, pub anchors: bool, pub handles: bool, + pub fillable_indicator: bool, } impl Default for OverlaysVisibilitySettings { @@ -78,18 +94,19 @@ impl Default for OverlaysVisibilitySettings { Self { all: true, artboard_name: true, - compass_rose: true, - quick_measurement: true, transform_measurement: true, + quick_measurement: true, transform_cage: true, + compass_rose: true, + pivot: true, + origin: true, hover_outline: true, selection_outline: true, layer_origin_cross: true, - pivot: true, - origin: true, path: true, anchors: true, handles: true, + fillable_indicator: true, } } } @@ -150,6 +167,10 @@ impl OverlaysVisibilitySettings { pub fn handles(&self) -> bool { self.all && self.anchors && self.handles } + + pub fn fillable_indicator(&self) -> bool { + self.all && self.fillable_indicator + } } #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] @@ -851,8 +872,7 @@ impl OverlayContext { self.text(&text, COLOR_OVERLAY_BLUE, None, transform, 16., [Pivot::Middle, Pivot::Middle]); } - /// Used by the Pen and Path tools to outline the path of the shape. - pub fn outline_vector(&mut self, vector: &Vector, transform: DAffine2) { + pub fn draw_path_from_vector_data(&mut self, vector: &Vector, transform: DAffine2) { self.start_dpi_aware_transform(); self.render_context.begin_path(); @@ -864,6 +884,12 @@ impl OverlayContext { self.bezier_command(bezier, transform, move_to); } + self.end_dpi_aware_transform(); + } + + /// Used by the Pen and Path tools to outline the path of the shape. + pub fn outline_vector(&mut self, vector: &Vector, transform: DAffine2) { + self.draw_path_from_vector_data(vector, transform); self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE); self.render_context.stroke(); @@ -928,39 +954,39 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - fn push_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2) { - self.start_dpi_aware_transform(); - + pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, close_path: bool, stroke_transform: DAffine2) { self.render_context.begin_path(); for subpath in subpaths { - let subpath = subpath.borrow(); - let mut curves = subpath.iter().peekable(); + let subpath = subpath.borrow().clone(); + let mut bezpath = subpath.to_bezpath(); + bezpath.apply_affine(Affine::new((stroke_transform).to_cols_array())); + let mut curves = bezpath.segments().peekable(); let Some(&first) = curves.peek() else { continue; }; - let start_point = transform.transform_point2(point_to_dvec2(first.start())); + let start_point = point_to_dvec2(first.start()); self.render_context.move_to(start_point.x, start_point.y); for curve in curves { match curve { PathSeg::Line(line) => { - let a = transform.transform_point2(point_to_dvec2(line.p1)); + let a = point_to_dvec2(line.p1); let a = a.round() - DVec2::splat(0.5); self.render_context.line_to(a.x, a.y); } PathSeg::Quad(quad_bez) => { - let a = transform.transform_point2(point_to_dvec2(quad_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(quad_bez.p2)); + let a = point_to_dvec2(quad_bez.p1); + let b = point_to_dvec2(quad_bez.p2); let a = a.round() - DVec2::splat(0.5); let b = b.round() - DVec2::splat(0.5); self.render_context.quadratic_curve_to(a.x, a.y, b.x, b.y); } PathSeg::Cubic(cubic_bez) => { - let a = transform.transform_point2(point_to_dvec2(cubic_bez.p1)); - let b = transform.transform_point2(point_to_dvec2(cubic_bez.p2)); - let c = transform.transform_point2(point_to_dvec2(cubic_bez.p3)); + let a = point_to_dvec2(cubic_bez.p1); + let b = point_to_dvec2(cubic_bez.p2); + let c = point_to_dvec2(cubic_bez.p3); let a = a.round() - DVec2::splat(0.5); let b = b.round() - DVec2::splat(0.5); let c = c.round() - DVec2::splat(0.5); @@ -969,16 +995,16 @@ impl OverlayContext { } } - if subpath.closed() { + if subpath.closed() && close_path { self.render_context.close_path(); } } - - self.end_dpi_aware_transform(); } /// Used by the Select tool to outline a path or a free point when selected or hovered. pub fn outline(&mut self, target_types: impl Iterator>, transform: DAffine2, color: Option<&str>) { + self.render_context.save(); + self.start_dpi_aware_transform(); let mut subpaths: Vec> = vec![]; target_types.for_each(|target_type| match target_type.borrow() { @@ -990,27 +1016,19 @@ impl OverlayContext { }); if !subpaths.is_empty() { - self.push_path(subpaths.iter(), transform); + self.draw_path_from_subpaths(subpaths.iter(), true, transform); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.render_context.set_stroke_style_str(color); self.render_context.set_line_width(1.); self.render_context.stroke(); } + self.end_dpi_aware_transform(); + self.render_context.restore(); } - /// Fills the area inside the path. Assumes `color` is in gamma space. - /// Used by the Pen tool to show the path being closed. - pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { - self.push_path(subpaths, transform); - - self.render_context.set_fill_style_str(color); - self.render_context.fill(); - } - - /// Fills the area inside the path with a pattern. Assumes `color` is an sRGB hex string. - /// Used by the fill tool to show the area to be filled. - pub fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + /// Default canvas pattern used for filling stroke or fill of a path. + fn fill_canvas_pattern(&self, color: &str) -> web_sys::CanvasPattern { const PATTERN_WIDTH: usize = 4; const PATTERN_HEIGHT: usize = 4; @@ -1041,14 +1059,163 @@ impl OverlayContext { let image_data = web_sys::ImageData::new_with_u8_clamped_array_and_sh(wasm_bindgen::Clamped(&data), PATTERN_WIDTH as u32, PATTERN_HEIGHT as u32).unwrap(); pattern_context.put_image_data(&image_data, 0., 0.).unwrap(); + let pattern = self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap(); + let dom_matrix = self.render_context.get_transform().unwrap().inverse(); + let set_pattern_transform = |pattern: &CanvasPattern, matrix: &DomMatrix| { + // Get the JS function: pattern.setTransform + let func = Reflect::get(pattern, &JsValue::from_str("setTransform"))?; + // Pass it the matrix + Reflect::apply(&func.into(), pattern, &Array::of1(matrix))?; + Ok::<(), JsValue>(()) + }; + let _ = set_pattern_transform(&pattern, &dom_matrix); - self.push_path(subpaths, transform); + return pattern; + } + + /// Used by the Pen tool to show the path being closed. + pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &str) { + self.draw_path_from_subpaths(subpaths, true, transform); - self.render_context.set_fill_style_canvas_pattern(&pattern); + self.render_context.set_fill_style_str(color); self.render_context.fill(); } + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is an sRGB hex string. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + self.render_context.save(); + self.start_dpi_aware_transform(); + + if let Some(stroke) = stroke { + let has_real_stroke = stroke.weight() > 0. && stroke.transform.matrix2.determinant() != 0.; + let applied_stroke_transform = if has_real_stroke { stroke.transform } else { transform }; + let element_transform = if has_real_stroke { transform * stroke.transform.inverse() } else { DAffine2::IDENTITY }; + + let [a, b, c, d, e, f] = element_transform.to_cols_array(); + self.render_context.transform(a, b, c, d, e, f).expect("element_transform should be set to render stroke properly"); + // TODO: mitigate stroke artifacts when strokes are rendered for closed paths. + self.draw_path_from_subpaths(subpaths, false, applied_stroke_transform); + + // For layers with open subpaths, stroke align is ignored and set to default + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + + let do_fill = || { + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.fill(); + }; + let do_stroke = |stroke_weight: f64| { + self.render_context.set_line_width(stroke_weight); + self.render_context.set_stroke_style_str(&"#000000"); + self.render_context.stroke(); + }; + let composite_mode = |composite_operation: &str| { + self.render_context + .set_global_composite_operation(composite_operation) + .expect("Failed to set global composite operation"); + }; + + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + do_fill(); + composite_mode("destination-out"); + do_stroke(stroke.weight() * 2.); + } + (StrokeAlign::Inside, PaintOrder::StrokeBelow) => do_fill(), + (StrokeAlign::Center, PaintOrder::StrokeAbove) => { + do_fill(); + composite_mode("destination-out"); + do_stroke(stroke.weight()); + } + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + do_fill(); + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + do_fill(); + } + } + } else { + self.draw_path_from_subpaths(subpaths, false, transform); + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.fill(); + } + + self.end_dpi_aware_transform(); + self.render_context.restore(); + } + + /// Fills the shape's stroke region with a pattern of the given color. Assumes `color` is an sRGB hex string. + /// WARN: Don't use source-in, destination-atop, destination-in, copy + /// on the main canvas as it will erase the existing overlays + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, is_closed_on_all: bool, transform: DAffine2, color: &str, stroke: Option) { + self.render_context.save(); + self.start_dpi_aware_transform(); + + if let Some(stroke) = stroke { + let has_real_stroke = stroke.weight() > 0. && stroke.transform.matrix2.determinant() != 0.; + let applied_stroke_transform = if has_real_stroke { stroke.transform } else { transform }; + let element_transform = if has_real_stroke { transform * stroke.transform.inverse() } else { DAffine2::IDENTITY }; + + let [a, b, c, d, e, f] = element_transform.to_cols_array(); + self.render_context.transform(a, b, c, d, e, f).expect("element_transform should be set to render stroke properly"); + // TODO: mitigate stroke artifacts when strokes are rendered for closed paths. + self.draw_path_from_subpaths(subpaths, false, applied_stroke_transform); + + // For layers with open subpaths, stroke align is ignored and set to default + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + + let do_stroke = |stroke_weight: f64| { + self.render_context.set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.set_line_width(stroke_weight); + self.render_context.set_line_cap(stroke.cap.html_canvas_name().as_str()); + self.render_context.set_line_join(stroke.join.html_canvas_name().as_str()); + self.render_context.set_miter_limit(stroke.join_miter_limit); + self.render_context.stroke(); + }; + let do_fill = || { + self.render_context.set_fill_style_str(&"#000000"); + self.render_context.fill(); + }; + let composite_mode = |composite_operation: &str| { + self.render_context + .set_global_composite_operation(composite_operation) + .expect("Failed to set global composite operation"); + }; + + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + // Clips away the stroke lying outside the path drawn from the subpaths + self.render_context.clip(); + do_stroke(stroke.weight() * 2.); + } + (StrokeAlign::Inside, PaintOrder::StrokeBelow) => {} + (StrokeAlign::Center, PaintOrder::StrokeAbove) => { + do_stroke(stroke.weight()); + } + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + do_stroke(stroke.weight()); + composite_mode("destination-out"); + do_fill(); + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + do_stroke(stroke.weight() * 2.); + composite_mode("destination-out"); + do_fill(); + } + } + } + + self.end_dpi_aware_transform(); + self.render_context.restore(); + } + + pub fn get_width(&self, text: &str) -> f64 { + self.render_context.measure_text(text).expect("Failed to measure text dimensions").width() + } + pub fn text(&self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { let metrics = self.render_context.measure_text(text).expect("Failed to measure the text dimensions"); let x = match pivot[0] { diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 496fa909c8..b00eddfcda 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -36,8 +36,7 @@ pub struct DocumentMetadata { pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_modify: HashMap, - /// Vector data keyed by layer ID, used as fallback when no Path node exists. - /// This provides accurate SegmentIds for layers without explicit Path nodes. + /// Vector data keyed by layer ID, used as fallback when no Path node exists; provides accurate SegmentIds for layers without explicit Path nodes. pub layer_vector_data: HashMap>, /// Transform from document space to viewport space. pub document_to_viewport: DAffine2, diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0253f707f6..7896a253f1 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -3231,6 +3231,10 @@ impl NodeNetworkInterface { return Some(modified); } + self.vector_data_from_layer(layer) + } + + pub fn vector_data_from_layer(&self, layer: LayerNodeIdentifier) -> Option { self.document_metadata.layer_vector_data.get(&layer).map(|arc| arc.as_ref().clone()) } diff --git a/editor/src/messages/tool/common_functionality/utility_functions.rs b/editor/src/messages/tool/common_functionality/utility_functions.rs index 567672498c..ed6bd34f40 100644 --- a/editor/src/messages/tool/common_functionality/utility_functions.rs +++ b/editor/src/messages/tool/common_functionality/utility_functions.rs @@ -2,6 +2,7 @@ use super::snapping::{SnapCandidatePoint, SnapData, SnapManager}; use super::transformation_cage::{BoundingBoxManager, SizeSnapData}; use crate::consts::ROTATE_INCREMENT; use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; +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::{NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::transformation::Selected; @@ -18,9 +19,10 @@ use graphene_std::renderer::Quad; use graphene_std::subpath::{Bezier, BezierHandles}; use graphene_std::text::FontCache; use graphene_std::vector::algorithms::bezpath_algorithms::pathseg_compute_lookup_table; -use graphene_std::vector::misc::{HandleId, ManipulatorPointId, dvec2_to_point}; -use graphene_std::vector::{HandleExt, PointId, SegmentId, Vector, VectorModification, VectorModificationType}; -use kurbo::{CubicBez, DEFAULT_ACCURACY, Line, ParamCurve, PathSeg, Point, QuadBez, Shape}; +use graphene_std::vector::misc::{HandleId, ManipulatorPointId, Tangent, dvec2_to_point, point_to_dvec2}; +use graphene_std::vector::style::{PaintOrder, Stroke, StrokeAlign}; +use graphene_std::vector::{HandleExt, PointId, SegmentId, Subpath, Vector, VectorModification, VectorModificationType}; +use kurbo::{Affine, CubicBez, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, QuadBez, Shape}; /// Determines if a path should be extended. Goal in viewport space. Returns the path and if it is extending from the start, if applicable. pub fn should_extend(document: &DocumentMessageHandler, goal: DVec2, tolerance: f64, layers: impl Iterator) -> Option<(LayerNodeIdentifier, PointId, DVec2)> { @@ -586,3 +588,103 @@ pub fn make_path_editable_is_allowed(network_interface: &mut NodeNetworkInterfac Some(first_layer) } + +pub fn near_to_subpath(mouse_pos: DVec2, subpath: Subpath, is_closed_on_all: bool, stroke: Option, layer_to_viewport: DAffine2, mut _overlay_context: Option) -> bool { + let daffine2_to_affine = |transform: DAffine2| -> Affine { Affine::new(transform.to_cols_array()) }; + + // Note: mouse position is in viewport space and subpaths are in the stroke/element space + let mut is_near = false; + + if let Some(stroke) = stroke { + let has_real_stroke = stroke.weight() > 0. && stroke.transform.matrix2.determinant() != 0.; + let element_transform = if has_real_stroke { layer_to_viewport * stroke.transform.inverse() } else { DAffine2::IDENTITY }; + let applied_stroke_transform = if has_real_stroke { stroke.transform } else { layer_to_viewport }; + + // Apply applied stroke transform to the subpath bezpath + let mut subpath_bezpath = subpath.to_bezpath(); + subpath_bezpath.apply_affine(daffine2_to_affine(applied_stroke_transform)); + + // Convert mouse position to element space + let mouse_pos_t = element_transform.inverse().transform_point2(mouse_pos); + let mouse_point = dvec2_to_point(mouse_pos_t); + + // Select the segment with the least distance from mouse position + let seg = subpath_bezpath.segments().min_by_key(|seg| seg.nearest(mouse_point, DEFAULT_ACCURACY).distance_sq.sqrt() as u64); + if let Some(seg) = seg { + let nearest = seg.nearest(mouse_point, DEFAULT_ACCURACY); + + // Preparing subpath bezpath to be used to check insideness + let test_subpath_bezpath = || { + let mut subpath_bezpath = subpath_bezpath.clone(); + subpath_bezpath.apply_affine(daffine2_to_affine(element_transform)); + if !subpath.closed() { + subpath_bezpath.close_path(); + } + subpath_bezpath + }; + let transform_seg = |transform: DAffine2| { + let mut bezpath_seg = seg.to_path(0.01); + bezpath_seg.apply_affine(daffine2_to_affine(transform)); + + bezpath_seg.get_seg(1).unwrap() + }; + + // Compute insideness and stroke scale while in element space + let (is_inside_seg, stroke_scale) = seg.tangent_at(nearest.t).try_normalize().map_or((false, 1.0), |tangent| { + // Transform the normal from element space to viewport space + let normal_dir = { + let subpath_bezpath = test_subpath_bezpath(); + let seg = transform_seg(element_transform); + let transformed_normal = (element_transform).transform_vector2(tangent.perp()); + let normal_end = dvec2_to_point(point_to_dvec2(seg.eval(nearest.t)) + ((stroke.weight() / 2.0) * transformed_normal)); + + if subpath_bezpath.contains(normal_end) { -1.0 } else { 1.0 } + }; + let normal = normal_dir * tangent.perp(); + let transformed_normal = (element_transform).transform_vector2(normal); + + let seg_to_mouse = (point_to_dvec2(mouse_point) - point_to_dvec2(seg.eval(nearest.t))).normalize_or(normal); + + // Draw debug transformed normal with length of stroke width + // if let Some(ref mut ctx) = overlay_context { + // let seg = transform_seg(element_transform); + + // let normal_start = point_to_dvec2(seg.eval(nearest.t)); + // let normal_end = normal_start + ((stroke.weight() / 2.0) * transformed_normal); + // ctx.line(normal_start, normal_end, Some("#d23434"), Some(2.0)); + // } + + (normal.dot(seg_to_mouse) <= 0.0, transformed_normal.length()) + }); + + // Adjust max stroke distance based on stroke properities and insideness + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + let mut max_stroke_distance = (stroke.weight() / 2.0) * stroke_scale; + match (stroke_align, stroke.paint_order, is_inside_seg) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove, true) => max_stroke_distance *= 2.0, + (StrokeAlign::Inside, PaintOrder::StrokeAbove, false) => max_stroke_distance = -f64::INFINITY, + (StrokeAlign::Inside, PaintOrder::StrokeBelow, _) => max_stroke_distance = -f64::INFINITY, + (StrokeAlign::Center, PaintOrder::StrokeAbove, _) => {} + (StrokeAlign::Center, PaintOrder::StrokeBelow, true) => max_stroke_distance = -f64::INFINITY, + (StrokeAlign::Center, PaintOrder::StrokeBelow, false) => {} + // Paint order does not affect StrokeAlign::Outside + (StrokeAlign::Outside, _, true) => max_stroke_distance = -f64::INFINITY, + (StrokeAlign::Outside, _, false) => max_stroke_distance *= 2.0, + } + + // Compute the nearest distance (subpath to mouse position in viewport space) + let nearest_distance = { + let seg = transform_seg(element_transform); + let mouse_point = dvec2_to_point(mouse_pos); + + seg.nearest(mouse_point, 0.01).distance_sq.sqrt() + }; + // log::info!("nearest_distance, max_stroke_distance: {:?}, {:?}", nearest_distance, max_stroke_distance); + if nearest_distance <= max_stroke_distance { + is_near = true; + } + } + } + + is_near +} diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index c1a2a8fe75..fb719b151b 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,8 +1,13 @@ use super::tool_prelude::*; +use crate::messages::portfolio::document::node_graph::document_node_definitions::DefinitionIdentifier; use crate::messages::portfolio::document::overlays::utility_types::OverlayContext; -use crate::messages::tool::common_functionality::graph_modification_utils::NodeGraphLayer; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer}; +use crate::messages::tool::common_functionality::utility_functions::near_to_subpath; use graphene_std::vector::style::Fill; +const STROKE_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); +const FILL_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER); + #[derive(Default, ExtractField)] pub struct FillTool { fsm_state: FillToolFsmState, @@ -105,14 +110,41 @@ impl Fsm for FillToolFsmState { let ToolMessage::Fill(event) = event else { return self }; match (self, event) { (_, FillToolMessage::Overlays { context: mut overlay_context }) => { + if !overlay_context.visibility_settings.fillable_indicator() { + return self; + } // Choose the working color to preview let use_secondary = input.keyboard.get(Key::Shift as usize); - let preview_color = if use_secondary { global_tool_data.secondary_color } else { global_tool_data.primary_color }; + let preview_color = (if use_secondary { global_tool_data.secondary_color } else { global_tool_data.primary_color }).to_rgba_hex_srgb(); + + // Get the layer the user is hovering + if let Some(layer) = document.click(input, viewport) + && let Some(vector_data) = document.network_interface.vector_data_from_layer(layer) + { + let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); - // Get the layer the user is hovering over - if let Some(layer) = document.click(input, viewport) { - let color_hex = format!("#{}", preview_color.to_rgba_hex_srgb()); - overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &color_hex); + // Stroke + let stroke_node = graph_layer.upstream_node_id_from_name(&STROKE_ID); + let stroke_exists_and_visible = stroke_node.is_some_and(|stroke| document.network_interface.is_visible(&stroke, &[])); + let stroke = vector_data.style.stroke(); + + let mut subpaths = vector_data.stroke_bezier_paths(); + // Subpaths on a layer is considered "closed" only if all subpaths are closed. + let is_closed_on_all = subpaths.all(|subpath| subpath.closed); + subpaths = vector_data.stroke_bezier_paths(); + let near_to_stroke = subpaths.any(|subpath| near_to_subpath(input.mouse.position, subpath, is_closed_on_all, stroke.clone(), layer_to_viewport, Some(overlay_context.clone()))); + + // Fill + let fill_node = graph_layer.upstream_node_id_from_name(&FILL_ID); + let fill_exists_and_visible = fill_node.is_some_and(|fill| document.network_interface.is_visible(&fill, &[])); + + subpaths = vector_data.stroke_bezier_paths(); + if stroke_exists_and_visible && near_to_stroke { + overlay_context.stroke_overlay(subpaths, is_closed_on_all, layer_to_viewport, preview_color.as_str(), stroke); + } else if fill_exists_and_visible { + overlay_context.fill_overlay(subpaths, is_closed_on_all, layer_to_viewport, preview_color.as_str(), stroke); + } } self @@ -123,11 +155,11 @@ impl Fsm for FillToolFsmState { self } (FillToolFsmState::Ready, color_event) => { - let Some(layer_identifier) = document.click(input, viewport) else { + let Some(layer) = document.click(input, viewport) else { return self; }; // If the layer is a raster layer, don't fill it, wait till the flood fill tool is implemented - if NodeGraphLayer::is_raster_layer(layer_identifier, &mut document.network_interface) { + if NodeGraphLayer::is_raster_layer(layer, &mut document.network_interface) { return self; } let fill = match color_event { @@ -135,9 +167,39 @@ impl Fsm for FillToolFsmState { FillToolMessage::FillSecondaryColor => Fill::Solid(global_tool_data.secondary_color.to_gamma_srgb()), _ => return self, }; + let stroke_color = match color_event { + FillToolMessage::FillPrimaryColor => global_tool_data.primary_color.to_gamma_srgb(), + FillToolMessage::FillSecondaryColor => global_tool_data.secondary_color.to_gamma_srgb(), + _ => return self, + }; responses.add(DocumentMessage::AddTransaction); - responses.add(GraphOperationMessage::FillSet { layer: layer_identifier, fill }); + + if let Some(vector_data) = document.network_interface.vector_data_from_layer(layer) { + let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); + + // Stroke + let stroke_node = graph_layer.upstream_node_id_from_name(&STROKE_ID); + let stroke_exists_and_visible = stroke_node.is_some_and(|stroke| document.network_interface.is_visible(&stroke, &[])); + let stroke = vector_data.style.stroke(); + + let mut subpaths = vector_data.stroke_bezier_paths(); + // Subpaths on a layer is considered "closed" only if all subpaths are closed. + let is_closed_on_all = subpaths.all(|subpath| subpath.closed); + subpaths = vector_data.stroke_bezier_paths(); + let near_to_stroke = subpaths.any(|subpath| near_to_subpath(input.mouse.position, subpath, is_closed_on_all, stroke.clone(), layer_to_viewport, None)); + + // Fill + let fill_node = graph_layer.upstream_node_id_from_name(&FILL_ID); + let fill_exists_and_visible = fill_node.is_some_and(|fill| document.network_interface.is_visible(&fill, &[])); + + if stroke_exists_and_visible && near_to_stroke { + responses.add(GraphOperationMessage::StrokeColorSet { layer, stroke_color }); + } else if fill_exists_and_visible { + responses.add(GraphOperationMessage::FillSet { layer, fill }); + } + } FillToolFsmState::Filling } diff --git a/editor/src/messages/tool/tool_messages/path_tool.rs b/editor/src/messages/tool/tool_messages/path_tool.rs index 713ea258f9..e4631b0029 100644 --- a/editor/src/messages/tool/tool_messages/path_tool.rs +++ b/editor/src/messages/tool/tool_messages/path_tool.rs @@ -2836,39 +2836,41 @@ impl Fsm for PathToolFsmState { let mut new_layers = Vec::new(); for (layer, new_vector, transform) in data { // If layer is not selected then create a new selected layer - let layer = if shape_editor.selected_shape_state.contains_key(&layer) { - layer - } else { - let Some(node_type) = resolve_network_node_type("Path") else { - error!("Could not resolve node type for Path"); - continue; - }; - let nodes = vec![(NodeId(0), node_type.default_node_template())]; + let layer = { + if shape_editor.selected_shape_state.contains_key(&layer) { + layer + } else { + let Some(node_type) = resolve_network_node_type("Path") else { + error!("Could not resolve node type for Path"); + continue; + }; + let nodes = vec![(NodeId(0), node_type.default_node_template())]; - let parent = document.new_layer_parent(false); + let parent = document.new_layer_parent(false); - let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); + let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); - // Defaults chosen because the pasted geometry has no inherent associated style - let stroke_color = Color::BLACK; - let fill_color = Color::WHITE; + // Defaults chosen because the pasted geometry has no inherent associated style + let stroke_color = Color::BLACK; + let fill_color = Color::WHITE; - let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH); - responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); + let stroke = graphene_std::vector::style::Stroke::new(Some(stroke_color.to_gamma_srgb()), DEFAULT_STROKE_WIDTH); + responses.add(GraphOperationMessage::StrokeSet { layer, stroke }); - let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb()); - responses.add(GraphOperationMessage::FillSet { layer, fill }); + let fill = graphene_std::vector::style::Fill::solid(fill_color.to_gamma_srgb()); + responses.add(GraphOperationMessage::FillSet { layer, fill }); - new_layers.push(layer); + new_layers.push(layer); - responses.add(GraphOperationMessage::TransformSet { - layer, - transform, - transform_in: TransformIn::Local, - skip_rerender: false, - }); + responses.add(GraphOperationMessage::TransformSet { + layer, + transform, + transform_in: TransformIn::Local, + skip_rerender: false, + }); - layer + layer + } }; // Create new point ids and add those into the existing vector content diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index e61aa7c468..01d66df8b3 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1684,6 +1684,49 @@ impl Fsm for PenToolFsmState { // The most recently placed anchor's outgoing handle (which is currently influencing the currently-being-placed segment) let handle_start = tool_data.latest_point().map(|point| transform.transform_point2(point.handle_start)); + // Display a filled overlay of the shape if the new point closes the path + if let Some(latest_point) = tool_data.latest_point() { + let handle_start = latest_point.handle_start; + let handle_end = tool_data.handle_end.unwrap_or(tool_data.next_handle_start); + let next_point = tool_data.next_point; + let start = latest_point.id; + + if let Some(layer) = layer + && let Some(mut vector) = document.network_interface.compute_modified_vector(layer) + { + let closest_point = vector.anchor_points().filter(|&id| id != start).find(|&id| { + vector.point_domain.position_from_id(id).is_some_and(|pos| { + let dist_sq = transform.transform_point2(pos).distance_squared(transform.transform_point2(next_point)); + dist_sq < crate::consts::SNAP_POINT_TOLERANCE.powi(2) + }) + }); + + // We have the point. Join the 2 vertices and check if any path is closed. + if let Some(end) = closest_point { + let segment_id = SegmentId::generate(); + vector.push(segment_id, start, end, (Some(handle_start), Some(handle_end)), StrokeId::ZERO); + + let grouped_segments = vector.auto_join_paths(); + let closed_paths = grouped_segments.iter().filter(|path| path.is_closed() && path.contains(segment_id)); + + let subpaths: Vec<_> = closed_paths + .filter_map(|path| { + let segments = path.edges.iter().filter_map(|edge| { + vector + .segment_domain + .iter() + .find(|(id, _, _, _)| id == &edge.id) + .map(|(_, start, end, bezier)| if start == edge.start { (bezier, start, end) } else { (bezier.reversed(), end, start) }) + }); + vector.subpath_from_segments(segments, true) + }) + .collect(); + + overlay_context.fill_path(subpaths.iter(), transform, COLOR_OVERLAY_BLUE_05); + } + } + } + if let (Some((start, handle_start)), Some(handle_end)) = (tool_data.latest_point().map(|point| (point.pos, point.handle_start)), tool_data.handle_end) { let end = tool_data.next_point; let bezier = PathSeg::Cubic(CubicBez::new(dvec2_to_point(start), dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(end))); @@ -1788,50 +1831,6 @@ impl Fsm for PenToolFsmState { } } - // Display a filled overlay of the shape if the new point closes the path - if let Some(latest_point) = tool_data.latest_point() { - let handle_start = latest_point.handle_start; - let handle_end = tool_data.handle_end.unwrap_or(tool_data.next_handle_start); - let next_point = tool_data.next_point; - let start = latest_point.id; - - if let Some(layer) = layer - && let Some(mut vector) = document.network_interface.compute_modified_vector(layer) - { - let closest_point = vector.anchor_points().filter(|&id| id != start).find(|&id| { - vector.point_domain.position_from_id(id).is_some_and(|pos| { - let dist_sq = transform.transform_point2(pos).distance_squared(transform.transform_point2(next_point)); - dist_sq < crate::consts::SNAP_POINT_TOLERANCE.powi(2) - }) - }); - - // We have the point. Join the 2 vertices and check if any path is closed. - if let Some(end) = closest_point { - let segment_id = SegmentId::generate(); - vector.push(segment_id, start, end, (Some(handle_start), Some(handle_end)), StrokeId::ZERO); - - let grouped_segments = vector.auto_join_paths(); - let closed_paths = grouped_segments.iter().filter(|path| path.is_closed() && path.contains(segment_id)); - - let subpaths: Vec<_> = closed_paths - .filter_map(|path| { - let segments = path.edges.iter().filter_map(|edge| { - vector - .segment_domain - .iter() - .find(|(id, _, _, _)| id == &edge.id) - .map(|(_, start, end, bezier)| if start == edge.start { (bezier, start, end) } else { (bezier.reversed(), end, start) }) - }); - vector.subpath_from_segments_ignore_discontinuities(segments) - }) - .collect(); - - let fill_color = COLOR_OVERLAY_BLUE_05; - overlay_context.fill_path(subpaths.iter(), transform, fill_color); - } - } - } - // Draw the overlays that visualize current snapping tool_data.snap_manager.draw_overlays(SnapData::new(document, input, viewport), &mut overlay_context); diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 4148d350ed..65eaaf5b8a 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -585,7 +585,7 @@ impl TaggedValue { pub fn to_u32(&self) -> u32 { match self { TaggedValue::U32(x) => *x, - _ => panic!("Passed value is not of type u32"), + _ => panic!("Cannot convert to type u32"), } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c7064992bb..71741d9645 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -21,7 +21,7 @@ use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; -use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, Stroke, StrokeAlign}; +use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign}; use graphic_types::{Artboard, Graphic, Vector}; use kurbo::{Affine, Cap, Join, Shape}; use num_traits::Zero; @@ -929,6 +929,7 @@ impl Render for List { let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); let applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); + let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse()); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let layer_bounds = vector.bounding_box().unwrap_or_default(); @@ -940,6 +941,7 @@ impl Render for List { let mut path = String::new(); for mut bezpath in vector.stroke_bezpath_iter() { + // Only affects layers with upstream transforms (from stroke node) bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); path.push_str(bezpath.to_svg().as_str()); } @@ -950,8 +952,8 @@ impl Render for List { MaskType::Mask }; - let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); - let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); + let only_closed_paths = vector.stroke_bezier_paths().all(|path| path.closed()); + let can_draw_aligned_stroke = only_closed_paths && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); let can_use_paint_order = !(vector.style.fill().is_none() || !vector.style.fill().is_opaque() || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; @@ -1021,6 +1023,7 @@ impl Render for List { render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); + // Affects all layers regardless of upstream/downstream transforms (from the stroke node) let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push(ATTR_TRANSFORM, matrix); @@ -1031,7 +1034,7 @@ impl Render for List { let mut svg = SvgRender::new(); vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); let stroke = vector.style.stroke().unwrap(); - // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` + // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `only_closed_paths` let (largest_scale, _) = singular_values(applied_stroke_transform); let inflation = stroke.max_aabb_inflation(true) * largest_scale; let quad = Quad::from_box(transformed_bounds).inflate(inflation); @@ -1109,7 +1112,7 @@ impl Render for List { } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - use graphic_types::vector_types::vector::style::{GradientType, StrokeCap, StrokeJoin}; + use graphic_types::vector_types::vector::style::GradientType; for index in 0..self.len() { use graphic_types::vector_types::vector; @@ -1152,7 +1155,7 @@ impl Render for List { let mut layer = false; // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). - // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since + // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `only_closed_paths` arg, equivalent here since // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. let stroke = element.style.stroke(); let can_draw_aligned_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); @@ -1270,23 +1273,13 @@ impl Render for List { Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]), None => peniko::Color::TRANSPARENT, }; - let cap = match stroke.cap { - StrokeCap::Butt => Cap::Butt, - StrokeCap::Round => Cap::Round, - StrokeCap::Square => Cap::Square, - }; - let join = match stroke.join { - StrokeJoin::Miter => Join::Miter, - StrokeJoin::Bevel => Join::Bevel, - StrokeJoin::Round => Join::Round, - }; let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); let stroke = kurbo::Stroke { width: stroke.weight * width_scale, miter_limit: stroke.join_miter_limit, - join, - start_cap: cap, - end_cap: cap, + join: stroke.join.to_kurbo(), + start_cap: stroke.cap.to_kurbo(), + end_cap: stroke.cap.to_kurbo(), dash_pattern, dash_offset: stroke.dash_offset, }; @@ -1489,17 +1482,22 @@ impl Render for List { /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, transform: DAffine2) { - let stroke_width = vector.style.stroke().as_ref().map_or(0., Stroke::effective_width); + let mut only_closed_paths = true; let filled = vector.style.fill() != &Fill::None; let subpaths: Vec> = vector .stroke_bezier_paths() .map(|mut subpath| { + // Our assumption is false if at least one subpath is open. + if !subpath.closed { + only_closed_paths = false; + } if filled { subpath.set_closed(true); } subpath }) .collect(); + let stroke_width = vector.style.stroke().as_ref().map_or(0., |stroke| stroke.effective_width(only_closed_paths)); if !subpaths.is_empty() { let mut click_target = ClickTarget::new_with_compound_path(subpaths, stroke_width); click_target.apply_transform(transform); diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index ff1d826211..bef113f645 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -243,6 +243,22 @@ impl StrokeCap { StrokeCap::Square => "square", } } + + pub fn html_canvas_name(&self) -> String { + match self { + StrokeCap::Butt => String::from("butt"), + StrokeCap::Round => String::from("round"), + StrokeCap::Square => String::from("square"), + } + } + + pub fn to_kurbo(&self) -> kurbo::Cap { + match self { + StrokeCap::Butt => kurbo::Cap::Butt, + StrokeCap::Round => kurbo::Cap::Round, + StrokeCap::Square => kurbo::Cap::Square, + } + } } #[repr(C)] @@ -265,6 +281,22 @@ impl StrokeJoin { StrokeJoin::Round => "round", } } + + pub fn html_canvas_name(&self) -> String { + match self { + StrokeJoin::Bevel => String::from("bevel"), + StrokeJoin::Miter => String::from("miter"), + StrokeJoin::Round => String::from("round"), + } + } + + pub fn to_kurbo(&self) -> kurbo::Join { + match self { + StrokeJoin::Bevel => kurbo::Join::Bevel, + StrokeJoin::Miter => kurbo::Join::Miter, + StrokeJoin::Round => kurbo::Join::Round, + } + } } #[repr(C)] @@ -348,6 +380,19 @@ impl Stroke { } } + /// Converts Stroke to kurbo::Stroke, lose of data is possible since some fields are non-existent in kurbo::Stroke + pub fn to_kurbo(&self) -> kurbo::Stroke { + kurbo::Stroke { + width: self.weight, + join: self.join.to_kurbo(), + miter_limit: self.join_miter_limit, + start_cap: self.cap.to_kurbo(), + end_cap: self.cap.to_kurbo(), + dash_offset: self.dash_offset, + ..Default::default() + } + } + pub fn lerp(&self, other: &Self, time: f64) -> Self { Self { color: self.color.map(|color| color.lerp(&other.color.unwrap_or(color), time as f32)), @@ -397,13 +442,18 @@ impl Stroke { } /// Get the effective stroke weight. - pub fn effective_width(&self) -> f64 { - self.weight - * match self.align { - StrokeAlign::Center => 1., - StrokeAlign::Inside => 0., - StrokeAlign::Outside => 2., - } + /// For open paths the renderer always draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here. + pub fn effective_width(&self, only_closed_paths: bool) -> f64 { + if only_closed_paths { + self.weight() + * match self.align { + StrokeAlign::Center => 1., + StrokeAlign::Inside => 0., + StrokeAlign::Outside => 2., + } + } else { + self.weight() + } } /// Worst-case upper bound on the perpendicular extent (per side) of the visible stroke from the path @@ -411,7 +461,7 @@ impl Stroke { /// Used as a cheap, safe inflation amount for renderer clip rects so alignment compositing layers /// don't crop the actual stroke geometry. Constant-time — no path traversal. /// - /// `path_is_closed` indicates whether every subpath of the vector being measured is closed. The renderer + /// `only_closed_paths` indicates whether every subpath of the vector being measured is closed. The renderer /// only honors stroke alignment for fully-closed paths and falls back to drawing a Center-aligned /// `weight`-wide stroke otherwise, so callers must pass `false` when any subpath is open or an /// `Inside`-aligned stroke would silently get an inflation of `0` and crop at the blend layer. @@ -420,13 +470,9 @@ impl Stroke { /// to reach the miter limit at every join (most don't), and square caps are assumed to sit at 45° to /// the axes (rarely the case). For an exact bound, use `Vector::stroke_inclusive_bounding_box_with_transform` /// at the cost of running kurbo to compute the stroke's outline path. - pub fn max_aabb_inflation(&self, path_is_closed: bool) -> f64 { + pub fn max_aabb_inflation(&self, only_closed_paths: bool) -> f64 { // Match the renderer: stroke alignment only applies to closed paths; open paths render as Center - let half_width = if self.align != StrokeAlign::Center && path_is_closed { - self.effective_width() - } else { - self.weight - } * 0.5; + let half_width = self.effective_width(only_closed_paths) * 0.5; let join_factor = if self.join == StrokeJoin::Miter { self.join_miter_limit.max(1.) } else { 1. }; let cap_factor = if self.cap == StrokeCap::Square { core::f64::consts::SQRT_2 } else { 1. }; half_width * join_factor.max(cap_factor) diff --git a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs index 58e6400ace..d6be098b98 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -1051,13 +1051,18 @@ impl Vector { } } - /// Construct a [`Bezier`] curve from an iterator of segments with (handles, start point, end point) independently of discontinuities. - pub fn subpath_from_segments_ignore_discontinuities(&self, segments: impl Iterator) -> Option> { + /// Construct a [`Bezier`] curve from an iterator of segments with (handles, start point, end point), optionally ignoring discontinuities. + /// Returns None if any ids are invalid or if the segments are not continuous. + pub fn subpath_from_segments(&self, segments: impl Iterator, ignore_discontinuities: bool) -> Option> { let mut first_point = None; let mut manipulators_list = Vec::new(); let mut last: Option<(usize, BezierHandles)> = None; for (handle, start, end) in segments { + if !ignore_discontinuities && last.is_some_and(|(previous_end, _)| previous_end != start) { + warn!("subpath_from_segments that were not continuous"); + return None; + } first_point = Some(first_point.unwrap_or(start)); manipulators_list.push(ManipulatorGroup { diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 96237b8ae6..9a451aa1d5 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -1,5 +1,5 @@ use super::misc::dvec2_to_point; -use super::style::{PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use super::style::{PathStyle, Stroke, StrokeCap, StrokeJoin}; pub use super::vector_attributes::*; use crate::subpath::{BezierHandles, ManipulatorGroup, Subpath}; use crate::vector::click_target::{ClickTargetType, FreePoint}; @@ -229,8 +229,8 @@ impl Vector { let Some(stroke) = self.style.stroke() else { return path_bounds }; // Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall // back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync. - let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed()); - let kurbo_width = if aligned_renders { stroke.effective_width() } else { stroke.weight }; + let only_closed_paths = self.stroke_bezier_paths().all(|p| p.closed()); + let kurbo_width = stroke.effective_width(only_closed_paths); // `Inside`-aligned strokes never expand beyond the path bounds; a zero-weight stroke is invisible if kurbo_width <= 0. { return path_bounds; diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index bdcd02ea22..fcb9b519c6 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -53,6 +53,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send + let footprint = Footprint::default(); let mut metadata = RenderMetadata::default(); + // All the metadata of the upstream Graphic(s) is collected here data.collect_metadata(&mut metadata, footprint, None); match &render_params.render_output_type { RenderOutputTypeRequest::Vello => { diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index acbb24bc16..63181fc395 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1136,16 +1136,7 @@ async fn offset_path(_: impl Ctx, content: List, distance: f64, join: St bezpath.apply_affine(transform); // Taking the existing stroke data and passing it to Kurbo to generate new paths. - let mut bezpath_out = offset_bezpath( - &bezpath, - -distance, - match join { - StrokeJoin::Miter => kurbo::Join::Miter, - StrokeJoin::Bevel => kurbo::Join::Bevel, - StrokeJoin::Round => kurbo::Join::Round, - }, - Some(miter_limit), - ); + let mut bezpath_out = offset_bezpath(&bezpath, -distance, join.to_kurbo(), Some(miter_limit)); bezpath_out.apply_affine(transform.inverse()); @@ -1176,16 +1167,8 @@ async fn solidify_stroke(_: impl Ctx, #[ let mut solidified_stroke = Vector::default(); // Taking the existing stroke data and passing it to kurbo::stroke to generate new fill paths. - let join = match stroke.join { - StrokeJoin::Miter => kurbo::Join::Miter, - StrokeJoin::Bevel => kurbo::Join::Bevel, - StrokeJoin::Round => kurbo::Join::Round, - }; - let cap = match stroke.cap { - StrokeCap::Butt => kurbo::Cap::Butt, - StrokeCap::Round => kurbo::Cap::Round, - StrokeCap::Square => kurbo::Cap::Square, - }; + let join = stroke.join.to_kurbo(); + let cap = stroke.cap.to_kurbo(); let dash_offset = stroke.dash_offset; let dash_pattern = stroke.dash_lengths; let miter_limit = stroke.join_miter_limit;