From d1612e7981bb03d20692b625f50c4a145f113488 Mon Sep 17 00:00:00 2001 From: seam0s Date: Wed, 18 Feb 2026 14:10:38 +0300 Subject: [PATCH 1/7] Implement fill tool on strokes --- .../document/document_message_handler.rs | 24 +++- .../graph_operation_message.rs | 6 +- .../graph_operation_message_handler.rs | 5 + .../document/graph_operation/utility_types.rs | 13 +- .../overlays/overlays_message_handler.rs | 14 +- .../document/overlays/utility_types_web.rs | 130 +++++++++++++----- .../utility_types/document_metadata.rs | 19 +++ .../messages/tool/tool_messages/fill_tool.rs | 124 ++++++++++++++++- .../messages/tool/tool_messages/pen_tool.rs | 92 ++++++------- node-graph/graph-craft/src/document/value.rs | 2 +- .../vector-types/src/vector/style.rs | 16 +++ .../src/vector/vector_attributes.rs | 9 +- 12 files changed, 352 insertions(+), 102 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 461e443a35..969719dde0 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1063,6 +1063,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); @@ -2319,7 +2320,7 @@ impl DocumentMessageHandler { widgets: { let checkbox_id = CheckboxId::new(); vec![ - CheckboxInput::new(self.overlays_visibility_settings.pivot) + CheckboxInput::new(self.overlays_visibility_settings.origin) .on_update(|optional_input: &CheckboxInput| { DocumentMessage::SetOverlaysVisibility { visible: optional_input.checked, @@ -2466,6 +2467,27 @@ impl DocumentMessageHandler { ] }, }, + LayoutGroup::Row { + widgets: vec![TextLabel::new("Fill Tool").widget_instance()], + }, + LayoutGroup::Row { + widgets: { + 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.clone()) + .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 8ebde695b5..0419845dad 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 @@ -4,7 +4,6 @@ use crate::messages::portfolio::document::utility_types::network_interface::Node use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; -use graphene_std::Artboard; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::{CPU, Raster}; @@ -14,6 +13,7 @@ use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::PointId; use graphene_std::vector::VectorModificationType; use graphene_std::vector::style::{Fill, Stroke}; +use graphene_std::{Artboard, Color}; #[impl_message(Message, DocumentMessage, GraphOperation)] #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)] @@ -41,6 +41,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 631bc1250b..d84f7fb57f 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 @@ -65,6 +65,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 d364a78774..8ff2ba1529 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -7,7 +7,6 @@ use glam::{DAffine2, IVec2}; use graph_craft::document::value::TaggedValue; use graph_craft::document::{NodeId, NodeInput}; use graph_craft::{ProtoNodeIdentifier, concrete}; -use graphene_std::Artboard; use graphene_std::brush::brush_stroke::BrushStroke; use graphene_std::raster::BlendMode; use graphene_std::raster_types::{CPU, Raster}; @@ -17,7 +16,7 @@ use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::vector::Vector; use graphene_std::vector::style::{Fill, Stroke}; use graphene_std::vector::{PointId, VectorModificationType}; -use graphene_std::{Graphic, NodeInputDecleration}; +use graphene_std::{Artboard, Color, Graphic, NodeInputDecleration}; #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] pub enum TransformIn { @@ -423,6 +422,16 @@ 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 stroke_color = if let Some(color) = color { Table::new_from_element(color) } else { Table::new() }; + let input_connector = InputConnector::node(stroke_node_id, 1); + self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Color(stroke_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 3bf436251c..ba639b0b06 100644 --- a/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs +++ b/editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs @@ -50,13 +50,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(), @@ -64,6 +57,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)))] 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 4fbaa5878d..a05a0ed0a6 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -15,6 +15,7 @@ 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::Stroke; use graphene_std::vector::{PointId, SegmentId, Vector}; use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; use std::collections::HashMap; @@ -30,38 +31,52 @@ pub fn empty_provider() -> OverlayProvider { /// Types of overlays used by DocumentMessage to enable/disable the selected set of viewport overlays. #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] 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, } #[derive(PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] -#[serde(default)] +#[serde(default = "OverlaysVisibilitySettings::default")] 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 { @@ -69,18 +84,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, } } } @@ -141,6 +157,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 + } } #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] @@ -805,8 +825,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(); @@ -818,6 +837,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(); @@ -882,7 +907,7 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - fn push_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2) { + pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, transform: DAffine2) { self.start_dpi_aware_transform(); self.render_context.begin_path(); @@ -943,7 +968,7 @@ impl OverlayContext { }); if !subpaths.is_empty() { - self.push_path(subpaths.iter(), transform); + self.draw_path_from_subpaths(subpaths.iter(), transform); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.render_context.set_stroke_style_str(color); @@ -952,18 +977,8 @@ impl OverlayContext { } } - /// 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 in gamma space. - /// 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: &Color) { + /// Default canvas pattern used for filling stroke or fill of a path. + fn fill_canvas_pattern(&self, color: &Color) -> web_sys::CanvasPattern { const PATTERN_WIDTH: usize = 4; const PATTERN_HEIGHT: usize = 4; @@ -992,12 +1007,61 @@ 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(); - - self.push_path(subpaths, transform); + return self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap(); + } - self.render_context.set_fill_style_canvas_pattern(&pattern); + /// Fills the area inside the path (with an optional pattern). Assumes `color` is in gamma space. + /// Used by the Pen tool to show the path being closed and by the Fill tool to show the area to be filled with a pattern. + pub fn fill_path( + &mut self, + subpaths: impl Iterator>>, + transform: DAffine2, + color: &Color, + with_pattern: bool, + clear_stroke_part: bool, + stroke_width: Option, + ) { + self.render_context.save(); + self.render_context.set_line_width(stroke_width.unwrap_or(1.)); + self.draw_path_from_subpaths(subpaths, transform); + + if with_pattern { + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + } else { + let color_str = format!("#{:?}", color.to_rgba_hex_srgb()); + self.render_context.set_fill_style_str(&color_str.as_str()); + } self.render_context.fill(); + + // Make the stroke transparent and erase the fill area overlapping the stroke. + if clear_stroke_part { + self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); + self.render_context.set_stroke_style_str(&"#000000"); + self.render_context.stroke(); + } + + self.render_context.restore(); + } + + pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, overlay_stroke: &Stroke) { + self.render_context.save(); + + // debug!("overlay_stroke.weight * ptz.zoom(): {:?}", overlay_stroke.weight); + self.render_context.set_line_width(overlay_stroke.weight); + self.draw_path_from_subpaths(subpaths, overlay_stroke.transform); + + self.render_context + .set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); + self.render_context.set_line_cap(overlay_stroke.cap.html_canvas_name().as_str()); + self.render_context.set_line_join(overlay_stroke.join.html_canvas_name().as_str()); + self.render_context.set_miter_limit(overlay_stroke.join_miter_limit); + self.render_context.stroke(); + + 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]) { 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 bc7b838eeb..ead4212c25 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -10,6 +10,7 @@ use graphene_std::math::quad::Quad; use graphene_std::subpath; use graphene_std::transform::Footprint; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; +use graphene_std::vector::style::Stroke; use graphene_std::vector::{PointId, Vector}; use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; @@ -86,6 +87,24 @@ impl DocumentMetadata { footprint * local_transform } + pub fn transform_to_viewport_with_stroke_transform(&self, layer: LayerNodeIdentifier, stroke: Stroke) -> DAffine2 { + // We're not allowed to convert the root parent to a node id + if layer == LayerNodeIdentifier::ROOT_PARENT { + return self.document_to_viewport; + } + + let footprint = self.upstream_footprints.get(&layer.to_node()).map(|footprint| footprint.transform).unwrap_or(self.document_to_viewport); + let local_transform = self.local_transforms.get(&layer.to_node()).copied().unwrap_or_default(); + + // let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); + // 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(*instance.transform); + // let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); + // let stroke_transform = self.upstream_transform(stroke_node); + + footprint * local_transform + } + pub fn transform_to_viewport_if_feeds(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> DAffine2 { // We're not allowed to convert the root parent to a node id if layer == LayerNodeIdentifier::ROOT_PARENT { diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index f29d73d1b7..021be08dd5 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,7 +1,14 @@ 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 graphene_std::vector::style::Fill; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer, get_stroke_width}; +use graph_craft::document::value::TaggedValue; +use graphene_std::NodeInputDecleration; +use graphene_std::subpath::Subpath; +use graphene_std::vector::PointId; +use graphene_std::vector::stroke::{CapInput, JoinInput, MiterLimitInput}; +use graphene_std::vector::style::{Fill, Stroke, StrokeCap, StrokeJoin}; +use kurbo::ParamCurveNearest; #[derive(Default, ExtractField)] pub struct FillTool { @@ -73,6 +80,34 @@ impl ToolTransition for FillTool { } } +pub fn close_to_subpath(mouse_pos: DVec2, subpath: Subpath, stroke_width: f64, _zoom: f64, layer_to_viewport_transform: DAffine2) -> bool { + let mouse_pos = layer_to_viewport_transform.inverse().transform_point2(mouse_pos); + let max_stroke_distance = stroke_width; + + let subpath_bezpath = subpath.to_bezpath(); + let mouse_point = kurbo::Point::new(mouse_pos.x, mouse_pos.y); + let mut is_close = false; + for seg in subpath_bezpath.segments() { + if seg.nearest(mouse_point, 0.01).distance_sq <= max_stroke_distance { + is_close = true; + } + } + + return is_close; + + // if let Some((segment_index, t)) = subpath.project(mouse_pos) { + // let nearest_point = subpath.evaluate(SubpathTValue::Parametric { segment_index, t }); + // // debug!("max_stroke_distance: {max_stroke_distance}"); + // // debug!("mouse-stroke distance: {:?}", (mouse_pos - nearest_point).length()); + // (mouse_pos - nearest_point).length_squared() <= max_stroke_distance + // } else { + // false + // } +} + +const STROKE_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); +const FILL_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER); + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum FillToolFsmState { #[default] @@ -108,9 +143,58 @@ impl Fsm for FillToolFsmState { 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 }; - // Get the layer the user is hovering over + if !overlay_context.visibility_settings.fillable_indicator() { + return self; + } + // Get the layer the user is hovering if let Some(layer) = document.click(input, viewport) { - overlay_context.fill_path_pattern(document.metadata().layer_outline(layer), document.metadata().transform_to_viewport(layer), &preview_color); + if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { + document.network_interface.compute_modified_vector(layer) + let mut subpaths = vector_data.stroke_bezier_paths(); + let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); + + // 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().unwrap(); + let stroke_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); + let zoom = document.document_ptz.zoom(); + let modified_stroke_width = stroke_width * zoom * 1.25; + let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); + + // 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(); + // let stroke_transform = document.network_interface.compute_modified_vector(layer).unwrap().style.stroke + if stroke_exists_and_visible && close_to_stroke { + let overlay_stroke = || { + let mut overlay_stroke = Stroke::new(Some(preview_color), modified_stroke_width); + overlay_stroke.transform = document.metadata().transform_to_viewport_with_stroke_transform(layer, stroke); + let line_cap = graph_layer.find_input(&STROKE_ID, CapInput::INDEX).unwrap(); + overlay_stroke.cap = if let TaggedValue::StrokeCap(line_cap) = line_cap { *line_cap } else { StrokeCap::default() }; + let line_join = graph_layer.find_input(&STROKE_ID, JoinInput::INDEX).unwrap(); + overlay_stroke.join = if let TaggedValue::StrokeJoin(line_join) = line_join { *line_join } else { StrokeJoin::default() }; + let miter_limit = graph_layer.find_input(&STROKE_ID, MiterLimitInput::INDEX).unwrap(); + overlay_stroke.join_miter_limit = if let TaggedValue::F64(miter_limit) = miter_limit { *miter_limit } else { f64::default() }; + + overlay_stroke + }; + + overlay_context.fill_stroke(subpaths, &overlay_stroke()); + } else if fill_exists_and_visible { + overlay_context.fill_path( + subpaths, + document.metadata().transform_to_viewport_with_stroke_transform(layer, stroke), + &preview_color, + true, + stroke_exists_and_visible, + Some(modified_stroke_width), + ); + } + } } self @@ -121,11 +205,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 { @@ -133,9 +217,35 @@ 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.compute_modified_vector(layer) { + let mut subpaths = vector_data.stroke_bezier_paths(); + let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); + + // 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_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); + let zoom = document.document_ptz.zoom(); + let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); + + // 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 && close_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/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index 599c1befd1..c9539dd66d 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1641,6 +1641,50 @@ 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(); + + let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()).unwrap().with_alpha(0.05); + overlay_context.fill_path(subpaths.iter(), transform, &fill_color, false, false, None); + } + } + } + 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))); @@ -1745,54 +1789,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 mut fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()) - .unwrap() - .with_alpha(0.05) - .to_rgba_hex_srgb(); - fill_color.insert(0, '#'); - overlay_context.fill_path(subpaths.iter(), transform, fill_color.as_str()); - } - } - } - // 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 fb483a0156..5efe59ab4b 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -392,7 +392,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/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 4330045f9a..87fda77441 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -225,6 +225,14 @@ 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"), + } + } } #[repr(C)] @@ -245,6 +253,14 @@ 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"), + } + } } #[repr(C)] 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 b8cfff2304..eea89c9207 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_attributes.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_attributes.rs @@ -927,13 +927,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 { From b1d47b8007d3a7708f8f99fa137b7b382ce19fa3 Mon Sep 17 00:00:00 2001 From: seam0s Date: Wed, 1 Apr 2026 22:16:46 +0300 Subject: [PATCH 2/7] Testing out transforms on the overlay strokes --- .../portfolio/document/overlays/mod.rs | 12 +++--- .../document/overlays/utility_types_native.rs | 42 ++++++++++++------- .../document/overlays/utility_types_web.rs | 41 +++++++++++++++--- .../utility_types/document_metadata.rs | 24 ++++++----- .../utility_types/network_interface.rs | 4 ++ .../messages/tool/tool_messages/fill_tool.rs | 15 ++++--- .../messages/tool/tool_messages/pen_tool.rs | 2 +- .../libraries/rendering/src/renderer.rs | 7 +++- 8 files changed, 99 insertions(+), 48 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/mod.rs b/editor/src/messages/portfolio/document/overlays/mod.rs index 4445dbfe84..8643000c8c 100644 --- a/editor/src/messages/portfolio/document/overlays/mod.rs +++ b/editor/src/messages/portfolio/document/overlays/mod.rs @@ -3,15 +3,15 @@ mod overlays_message; mod overlays_message_handler; pub mod utility_functions; // Native (non‑wasm) -#[cfg(not(target_family = "wasm"))] -pub mod utility_types_native; -#[cfg(not(target_family = "wasm"))] -pub use utility_types_native as utility_types; +// #[cfg(not(target_family = "wasm"))] +// pub mod utility_types_native; +// #[cfg(not(target_family = "wasm"))] +// pub use utility_types_native as utility_types; // WebAssembly -#[cfg(target_family = "wasm")] +// #[cfg(target_family = "wasm")] pub mod utility_types_web; -#[cfg(target_family = "wasm")] +// #[cfg(target_family = "wasm")] pub use utility_types_web as utility_types; #[doc(inline)] 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 2e5afb04c9..d4e6d75337 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -17,6 +17,7 @@ use graphene_std::table::Table; 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::Stroke; use graphene_std::vector::{PointId, SegmentId, Vector}; use kurbo::{self, BezPath, ParamCurve}; use kurbo::{Affine, PathSeg}; @@ -937,7 +938,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>>, transform: DAffine2) -> BezPath { let mut path = BezPath::new(); for subpath in subpaths { @@ -1000,24 +1001,14 @@ impl OverlayContextInternal { } if !subpaths.is_empty() { - let path = self.push_path(subpaths.iter(), transform); + let path = self.path_from_subpaths(subpaths.iter(), 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 in gamma space. - /// Used by the fill tool to show the area to be filled. - fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { + pub fn fill_canvas_pattern_image(&self, color: &Color) -> peniko::ImageBrush { const PATTERN_WIDTH: u32 = 4; const PATTERN_HEIGHT: u32 = 4; @@ -1054,12 +1045,33 @@ 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, 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 in gamma space. + /// Used by the fill tool to show the area to be filled. + fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { + let path = self.path_from_subpaths(subpaths, transform); + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(color)); self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path); } + pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, transform: DAffine2, overlay_stroke: &Stroke) { + let path = self.path_from_subpaths(subpaths, transform); + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); + + self.scene.stroke(&kurbo::Stroke::new(overlay_stroke.weight), self.get_transform(), &brush, None, &path); + } + fn text(&mut self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { // Use the proper text-to-path system for accurate text rendering const FONT_SIZE: f64 = 12.; 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 a05a0ed0a6..9c8a9ccf75 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -907,12 +907,18 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, transform: DAffine2) { + pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, transform: DAffine2, stroke_transform: Option) { self.start_dpi_aware_transform(); + // let a = stroke_transform.matrix2.x_axis.x; + // let b = stroke_transform.matrix2.y_axis.x; + // let c = stroke_transform.matrix2.x_axis.y; + // let d = stroke_transform.matrix2.y_axis.y; + // let e = stroke_transform.translation.x; + // let f = stroke_transform.translation.y; self.render_context.begin_path(); for subpath in subpaths { - let subpath = subpath.borrow(); + let mut subpath = subpath.borrow().clone(); let mut curves = subpath.iter().peekable(); let Some(&first) = curves.peek() else { @@ -968,7 +974,7 @@ impl OverlayContext { }); if !subpaths.is_empty() { - self.draw_path_from_subpaths(subpaths.iter(), transform); + self.draw_path_from_subpaths(subpaths.iter(), transform, None); let color = color.unwrap_or(COLOR_OVERLAY_BLUE); self.render_context.set_stroke_style_str(color); @@ -1016,6 +1022,7 @@ impl OverlayContext { &mut self, subpaths: impl Iterator>>, transform: DAffine2, + stroke_transform: DAffine2, color: &Color, with_pattern: bool, clear_stroke_part: bool, @@ -1023,7 +1030,7 @@ impl OverlayContext { ) { self.render_context.save(); self.render_context.set_line_width(stroke_width.unwrap_or(1.)); - self.draw_path_from_subpaths(subpaths, transform); + self.draw_path_from_subpaths(subpaths, transform, Some(stroke_transform)); if with_pattern { self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); @@ -1035,20 +1042,41 @@ impl OverlayContext { // Make the stroke transparent and erase the fill area overlapping the stroke. if clear_stroke_part { + // self.render_context.save(); + // let stroke_transform = Some(stroke_transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); + // let a = stroke_transform.matrix2.x_axis.x; + // let b = stroke_transform.matrix2.y_axis.x; + // let c = stroke_transform.matrix2.x_axis.y; + // let d = stroke_transform.matrix2.y_axis.y; + // let e = stroke_transform.translation.x; + // let f = stroke_transform.translation.y; + // self.render_context.set_transform(a, b, c, d, e, f); + self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); self.render_context.set_stroke_style_str(&"#000000"); self.render_context.stroke(); + // self.render_context.restore(); } self.render_context.restore(); } - pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, overlay_stroke: &Stroke) { + pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, transform: DAffine2, overlay_stroke: &Stroke) { self.render_context.save(); // debug!("overlay_stroke.weight * ptz.zoom(): {:?}", overlay_stroke.weight); self.render_context.set_line_width(overlay_stroke.weight); - self.draw_path_from_subpaths(subpaths, overlay_stroke.transform); + self.draw_path_from_subpaths(subpaths, transform, Some(overlay_stroke.transform)); + + // self.render_context.save(); + // let stroke_transform = Some(overlay_stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); + // let a = stroke_transform.matrix2.x_axis.x; + // let b = stroke_transform.matrix2.y_axis.x; + // let c = stroke_transform.matrix2.x_axis.y; + // let d = stroke_transform.matrix2.y_axis.y; + // let e = stroke_transform.translation.x; + // let f = stroke_transform.translation.y; + // self.render_context.set_transform(a, b, c, d, e, f); self.render_context .set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); @@ -1056,6 +1084,7 @@ impl OverlayContext { self.render_context.set_line_join(overlay_stroke.join.html_canvas_name().as_str()); self.render_context.set_miter_limit(overlay_stroke.join_miter_limit); self.render_context.stroke(); + // self.render_context.restore(); self.render_context.restore(); } 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 ead4212c25..27d24cde6a 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -87,22 +87,26 @@ impl DocumentMetadata { footprint * local_transform } - pub fn transform_to_viewport_with_stroke_transform(&self, layer: LayerNodeIdentifier, stroke: Stroke) -> DAffine2 { + pub fn transform_to_viewport_with_stroke_transform(&self, layer: LayerNodeIdentifier, vector: Vector) -> DAffine2 { // We're not allowed to convert the root parent to a node id if layer == LayerNodeIdentifier::ROOT_PARENT { return self.document_to_viewport; } let footprint = self.upstream_footprints.get(&layer.to_node()).map(|footprint| footprint.transform).unwrap_or(self.document_to_viewport); - let local_transform = self.local_transforms.get(&layer.to_node()).copied().unwrap_or_default(); - - // let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); - // 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(*instance.transform); - // let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); - // let stroke_transform = self.upstream_transform(stroke_node); - - footprint * local_transform + let local_transform_for_layer = self.local_transforms.get(&layer.to_node()).copied().unwrap_or_default(); + + let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); + let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); + if let Some(stroke_transform) = set_stroke_transform { + // Both upstream and downstream (from stroke node) contains stroke transforms + // which shouldn't be the case. This branch should only execute for downstreamed. + footprint * stroke_transform * local_transform_for_layer * stroke_transform.inverse() + } else { + // This branch is not executed on upstream or downstream transforms (from stroke node) layers. + // This branch should only execute for upstreamed. + footprint * local_transform_for_layer + } } pub fn transform_to_viewport_if_feeds(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> 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 22680231e5..bd0e533f25 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -3086,6 +3086,10 @@ impl NodeNetworkInterface { self.document_metadata.layer_vector_data.get(&layer).map(|arc| arc.as_ref().clone()) } + pub fn vector_data_from_layer(&self, layer: LayerNodeIdentifier) -> Option { + self.document_metadata.layer_vector_data.get(&layer).map(|arc| arc.as_ref().clone()) + } + /// Loads the structure of layer nodes from a node graph. pub fn load_structure(&mut self) { self.document_metadata.structure = HashMap::from_iter([(LayerNodeIdentifier::ROOT_PARENT, NodeRelations::default())]); diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 021be08dd5..fe8cfd54d6 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -148,8 +148,7 @@ impl Fsm for FillToolFsmState { } // Get the layer the user is hovering if let Some(layer) = document.click(input, viewport) { - if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { - document.network_interface.compute_modified_vector(layer) + if let Some(vector_data) = document.network_interface.vector_data_from_layer(layer) { let mut subpaths = vector_data.stroke_bezier_paths(); let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); @@ -157,10 +156,10 @@ impl Fsm for FillToolFsmState { 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().unwrap(); + let stroke = vector_data.style.stroke(); let stroke_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); let zoom = document.document_ptz.zoom(); - let modified_stroke_width = stroke_width * zoom * 1.25; + let modified_stroke_width = stroke_width * zoom; let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); // Fill @@ -168,11 +167,10 @@ impl Fsm for FillToolFsmState { let fill_exists_and_visible = fill_node.is_some_and(|fill| document.network_interface.is_visible(&fill, &[])); subpaths = vector_data.stroke_bezier_paths(); - // let stroke_transform = document.network_interface.compute_modified_vector(layer).unwrap().style.stroke if stroke_exists_and_visible && close_to_stroke { let overlay_stroke = || { let mut overlay_stroke = Stroke::new(Some(preview_color), modified_stroke_width); - overlay_stroke.transform = document.metadata().transform_to_viewport_with_stroke_transform(layer, stroke); + overlay_stroke.transform = DAffine2::IDENTITY; let line_cap = graph_layer.find_input(&STROKE_ID, CapInput::INDEX).unwrap(); overlay_stroke.cap = if let TaggedValue::StrokeCap(line_cap) = line_cap { *line_cap } else { StrokeCap::default() }; let line_join = graph_layer.find_input(&STROKE_ID, JoinInput::INDEX).unwrap(); @@ -183,11 +181,12 @@ impl Fsm for FillToolFsmState { overlay_stroke }; - overlay_context.fill_stroke(subpaths, &overlay_stroke()); + overlay_context.fill_stroke(subpaths, document.metadata().transform_to_viewport_with_stroke_transform(layer, vector_data.clone()), &overlay_stroke()); } else if fill_exists_and_visible { overlay_context.fill_path( subpaths, - document.metadata().transform_to_viewport_with_stroke_transform(layer, stroke), + document.metadata().transform_to_viewport_with_stroke_transform(layer, vector_data.clone()), + DAffine2::IDENTITY, &preview_color, true, stroke_exists_and_visible, diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index c9539dd66d..a54eb6796b 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1680,7 +1680,7 @@ impl Fsm for PenToolFsmState { .collect(); let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()).unwrap().with_alpha(0.05); - overlay_context.fill_path(subpaths.iter(), transform, &fill_color, false, false, None); + overlay_context.fill_path(subpaths.iter(), transform, DAffine2::IDENTITY, &fill_color, false, false, None); } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 74372383ac..b4a3af3e72 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -725,14 +725,13 @@ impl Render for Table { impl Render for Table { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for row in self.iter() { - let multiplied_transform = *row.transform; let vector = &row.element; // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); 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(*row.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 = set_stroke_transform.map(|stroke_transform| *row.transform * stroke_transform.inverse()); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let layer_bounds = vector.bounding_box().unwrap_or_default(); let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); @@ -743,6 +742,7 @@ impl Render for Table { let mut path = String::new(); for mut bezpath in row.element.stroke_bezpath_iter() { + // Only affects upstream-transformed (from stroke node) layers with row.transform bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); path.push_str(bezpath.to_svg().as_str()); } @@ -826,6 +826,8 @@ impl Render for Table { render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); + // Only affects layers with downstream-transformed layers (from stroke node) with row.transform*stroke_transform.inverse() + // and affect layers with upstream-transformed (from stroke node) layers with IDENTITY let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push("transform", matrix); @@ -871,6 +873,7 @@ impl Render for Table { let selector = format!("url(#{id})"); attributes.push(mask_type.to_attribute(), selector); } + // Look here to see how to adjust the stroke attributes.push_val(fill_and_stroke); let opacity = row.alpha_blending.opacity(render_params.for_mask); From c5f2a4f5988036a928d495ad27a1a0d6cedf5c67 Mon Sep 17 00:00:00 2001 From: seam0s Date: Mon, 13 Apr 2026 22:09:06 +0300 Subject: [PATCH 3/7] Fixed transforms on overlay strokes using stroke transform --- .../document/overlays/utility_types_web.rs | 106 ++++++++++-------- .../utility_types/document_metadata.rs | 22 ---- .../messages/tool/tool_messages/fill_tool.rs | 38 +++++-- .../libraries/rendering/src/renderer.rs | 14 ++- node-graph/nodes/gstd/src/render_node.rs | 1 + 5 files changed, 98 insertions(+), 83 deletions(-) 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 9c8a9ccf75..623b3b4450 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -12,9 +12,11 @@ use core::f64::consts::{FRAC_PI_2, PI, TAU}; use glam::{DAffine2, DVec2}; use graphene_std::Color; use graphene_std::math::quad::Quad; +use graphene_std::raster::curve; 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::stroke::DashLengthsInput; use graphene_std::vector::style::Stroke; use graphene_std::vector::{PointId, SegmentId, Vector}; use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; @@ -907,45 +909,50 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, transform: DAffine2, stroke_transform: Option) { - self.start_dpi_aware_transform(); - - // let a = stroke_transform.matrix2.x_axis.x; - // let b = stroke_transform.matrix2.y_axis.x; - // let c = stroke_transform.matrix2.x_axis.y; - // let d = stroke_transform.matrix2.y_axis.y; - // let e = stroke_transform.translation.x; - // let f = stroke_transform.translation.y; + pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, stroke_transform: DAffine2) { + // self.render_context.save(); + // self.start_dpi_aware_transform(); + + // let a = transform.matrix2.x_axis.x; + // let b = transform.matrix2.y_axis.x; + // let c = transform.matrix2.x_axis.y; + // let d = transform.matrix2.y_axis.y; + // let e = transform.translation.x; + // let f = transform.translation.y; + // self.render_context.transform(a, b, c, d, e, f); + // self.render_context.set_transform(a, b, c, d, e, f); self.render_context.begin_path(); for subpath in subpaths { - let mut subpath = subpath.borrow().clone(); - 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); @@ -959,11 +966,14 @@ impl OverlayContext { } } - self.end_dpi_aware_transform(); + // self.end_dpi_aware_transform(); + // self.render_context.restore(); } /// 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() { @@ -974,13 +984,16 @@ impl OverlayContext { }); if !subpaths.is_empty() { - self.draw_path_from_subpaths(subpaths.iter(), transform, None); + // TODO: Modify stroke_transform to take note of this + self.draw_path_from_subpaths(subpaths.iter(), 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(); } /// Default canvas pattern used for filling stroke or fill of a path. @@ -1029,8 +1042,7 @@ impl OverlayContext { stroke_width: Option, ) { self.render_context.save(); - self.render_context.set_line_width(stroke_width.unwrap_or(1.)); - self.draw_path_from_subpaths(subpaths, transform, Some(stroke_transform)); + self.start_dpi_aware_transform(); if with_pattern { self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); @@ -1038,54 +1050,52 @@ impl OverlayContext { let color_str = format!("#{:?}", color.to_rgba_hex_srgb()); self.render_context.set_fill_style_str(&color_str.as_str()); } + // let stroke_transform = Some(stroke_transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); + let a = transform.matrix2.x_axis.x; + let b = transform.matrix2.y_axis.x; + let c = transform.matrix2.x_axis.y; + let d = transform.matrix2.y_axis.y; + let e = transform.translation.x; + let f = transform.translation.y; + self.render_context.transform(a, b, c, d, e, f); + self.draw_path_from_subpaths(subpaths, stroke_transform); self.render_context.fill(); // Make the stroke transparent and erase the fill area overlapping the stroke. if clear_stroke_part { - // self.render_context.save(); - // let stroke_transform = Some(stroke_transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); - // let a = stroke_transform.matrix2.x_axis.x; - // let b = stroke_transform.matrix2.y_axis.x; - // let c = stroke_transform.matrix2.x_axis.y; - // let d = stroke_transform.matrix2.y_axis.y; - // let e = stroke_transform.translation.x; - // let f = stroke_transform.translation.y; - // self.render_context.set_transform(a, b, c, d, e, f); - + self.render_context.set_line_width(stroke_width.unwrap_or(1.)); self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); self.render_context.set_stroke_style_str(&"#000000"); self.render_context.stroke(); - // self.render_context.restore(); } + self.end_dpi_aware_transform(); self.render_context.restore(); } pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, transform: DAffine2, overlay_stroke: &Stroke) { self.render_context.save(); - - // debug!("overlay_stroke.weight * ptz.zoom(): {:?}", overlay_stroke.weight); - self.render_context.set_line_width(overlay_stroke.weight); - self.draw_path_from_subpaths(subpaths, transform, Some(overlay_stroke.transform)); - - // self.render_context.save(); - // let stroke_transform = Some(overlay_stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); - // let a = stroke_transform.matrix2.x_axis.x; - // let b = stroke_transform.matrix2.y_axis.x; - // let c = stroke_transform.matrix2.x_axis.y; - // let d = stroke_transform.matrix2.y_axis.y; - // let e = stroke_transform.translation.x; - // let f = stroke_transform.translation.y; - // self.render_context.set_transform(a, b, c, d, e, f); + self.start_dpi_aware_transform(); self.render_context .set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); + self.render_context.set_line_width(overlay_stroke.weight); self.render_context.set_line_cap(overlay_stroke.cap.html_canvas_name().as_str()); self.render_context.set_line_join(overlay_stroke.join.html_canvas_name().as_str()); self.render_context.set_miter_limit(overlay_stroke.join_miter_limit); + // let stroke_transform = Some(overlay_stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); + // let stroke_transform = overlay_stroke.transform; + let a = transform.matrix2.x_axis.x; + let b = transform.matrix2.y_axis.x; + let c = transform.matrix2.x_axis.y; + let d = transform.matrix2.y_axis.y; + let e = transform.translation.x; + let f = transform.translation.y; + self.render_context.transform(a, b, c, d, e, f); + self.draw_path_from_subpaths(subpaths, overlay_stroke.transform); self.render_context.stroke(); - // self.render_context.restore(); + self.end_dpi_aware_transform(); self.render_context.restore(); } 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 27d24cde6a..5173b77834 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -87,28 +87,6 @@ impl DocumentMetadata { footprint * local_transform } - pub fn transform_to_viewport_with_stroke_transform(&self, layer: LayerNodeIdentifier, vector: Vector) -> DAffine2 { - // We're not allowed to convert the root parent to a node id - if layer == LayerNodeIdentifier::ROOT_PARENT { - return self.document_to_viewport; - } - - let footprint = self.upstream_footprints.get(&layer.to_node()).map(|footprint| footprint.transform).unwrap_or(self.document_to_viewport); - let local_transform_for_layer = self.local_transforms.get(&layer.to_node()).copied().unwrap_or_default(); - - let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); - let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); - if let Some(stroke_transform) = set_stroke_transform { - // Both upstream and downstream (from stroke node) contains stroke transforms - // which shouldn't be the case. This branch should only execute for downstreamed. - footprint * stroke_transform * local_transform_for_layer * stroke_transform.inverse() - } else { - // This branch is not executed on upstream or downstream transforms (from stroke node) layers. - // This branch should only execute for upstreamed. - footprint * local_transform_for_layer - } - } - pub fn transform_to_viewport_if_feeds(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> DAffine2 { // We're not allowed to convert the root parent to a node id if layer == LayerNodeIdentifier::ROOT_PARENT { diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index fe8cfd54d6..b46e073615 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -6,8 +6,8 @@ use graph_craft::document::value::TaggedValue; use graphene_std::NodeInputDecleration; use graphene_std::subpath::Subpath; use graphene_std::vector::PointId; -use graphene_std::vector::stroke::{CapInput, JoinInput, MiterLimitInput}; -use graphene_std::vector::style::{Fill, Stroke, StrokeCap, StrokeJoin}; +use graphene_std::vector::stroke::{AlignInput, CapInput, JoinInput, MiterLimitInput, PaintOrderInput}; +use graphene_std::vector::style::{Fill, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use kurbo::ParamCurveNearest; #[derive(Default, ExtractField)] @@ -155,11 +155,18 @@ impl Fsm for FillToolFsmState { // 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 has_real_stroke = stroke.filter(|stroke| stroke.weight() > 0.); + 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(document.metadata().transform_to_viewport(layer)); + let element_transform = set_stroke_transform + .map(|stroke_transform| document.metadata().transform_to_viewport(layer) * stroke_transform.inverse()) + .unwrap_or(DAffine2::IDENTITY); + let stroke_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); - let zoom = document.document_ptz.zoom(); - let modified_stroke_width = stroke_width * zoom; + let zoom: f64 = document.document_ptz.zoom(); + let modified_stroke_width = stroke_width; let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); // Fill @@ -170,23 +177,36 @@ impl Fsm for FillToolFsmState { if stroke_exists_and_visible && close_to_stroke { let overlay_stroke = || { let mut overlay_stroke = Stroke::new(Some(preview_color), modified_stroke_width); - overlay_stroke.transform = DAffine2::IDENTITY; + overlay_stroke.transform = applied_stroke_transform; + + let align = graph_layer.find_input(&STROKE_ID, AlignInput::INDEX).unwrap(); + overlay_stroke.align = if let TaggedValue::StrokeAlign(align) = align { *align } else { StrokeAlign::default() }; + let line_cap = graph_layer.find_input(&STROKE_ID, CapInput::INDEX).unwrap(); overlay_stroke.cap = if let TaggedValue::StrokeCap(line_cap) = line_cap { *line_cap } else { StrokeCap::default() }; + let line_join = graph_layer.find_input(&STROKE_ID, JoinInput::INDEX).unwrap(); overlay_stroke.join = if let TaggedValue::StrokeJoin(line_join) = line_join { *line_join } else { StrokeJoin::default() }; + let miter_limit = graph_layer.find_input(&STROKE_ID, MiterLimitInput::INDEX).unwrap(); overlay_stroke.join_miter_limit = if let TaggedValue::F64(miter_limit) = miter_limit { *miter_limit } else { f64::default() }; + let paint_order = graph_layer.find_input(&STROKE_ID, PaintOrderInput::INDEX).unwrap(); + overlay_stroke.paint_order = if let TaggedValue::PaintOrder(paint_order) = paint_order { + *paint_order + } else { + PaintOrder::default() + }; + overlay_stroke }; - overlay_context.fill_stroke(subpaths, document.metadata().transform_to_viewport_with_stroke_transform(layer, vector_data.clone()), &overlay_stroke()); + overlay_context.fill_stroke(subpaths, element_transform, &overlay_stroke()); } else if fill_exists_and_visible { overlay_context.fill_path( subpaths, - document.metadata().transform_to_viewport_with_stroke_transform(layer, vector_data.clone()), - DAffine2::IDENTITY, + element_transform, + applied_stroke_transform, &preview_color, true, stroke_exists_and_visible, diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index b4a3af3e72..783479e70d 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -727,10 +727,11 @@ impl Render for Table { for row in self.iter() { let vector = &row.element; // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform - let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); + let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 1.); 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(*row.transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); + let element_transform = set_stroke_transform.map(|stroke_transform| *row.transform * stroke_transform.inverse()); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let layer_bounds = vector.bounding_box().unwrap_or_default(); @@ -742,7 +743,7 @@ impl Render for Table { let mut path = String::new(); for mut bezpath in row.element.stroke_bezpath_iter() { - // Only affects upstream-transformed (from stroke node) layers with row.transform + // Only seems to affect upstream-transformed (from stroke node) layers ~~with row.transform~~ bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); path.push_str(bezpath.to_svg().as_str()); } @@ -761,6 +762,7 @@ impl Render for Table { let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); if needs_separate_alignment_fill && !wants_stroke_below { + log::info!("Entering needs_separate_alignment_fill && !wants_stroke_below"); render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); let matrix = format_transform_matrix(element_transform); @@ -799,6 +801,7 @@ impl Render for Table { }); if vector.is_branching() { + log::info!("Entering vector.is_branching()"); for mut face_path in vector.construct_faces().filter(|face| !(face.area() < 0.0)) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); @@ -826,8 +829,8 @@ impl Render for Table { render.leaf_tag("path", |attributes| { attributes.push("d", path.clone()); - // Only affects layers with downstream-transformed layers (from stroke node) with row.transform*stroke_transform.inverse() - // and affect layers with upstream-transformed (from stroke node) layers with IDENTITY + // Only seem to affect layers with downstream-transformed layers (from stroke node) with ~~row.transform*stroke_transform.inverse()~~ + // and affect layers with upstream-transformed (from stroke node) layers ~~with IDENTITY~~ let matrix = format_transform_matrix(element_transform); if !matrix.is_empty() { attributes.push("transform", matrix); @@ -835,6 +838,7 @@ impl Render for Table { let defs = &mut attributes.0.svg_defs; if let Some((ref id, mask_type, ref vector_row)) = push_id { + log::info!("Entering Some(p) = push_id"); let mut svg = SvgRender::new(); vector_row.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); let stroke = row.element.style.stroke().unwrap(); @@ -868,6 +872,7 @@ impl Render for Table { } let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params); + // log::info!("file_and_stroke: {:?}", fill_and_stroke); if let Some((id, mask_type, _)) = push_id { let selector = format!("url(#{id})"); @@ -888,6 +893,7 @@ impl Render for Table { // When splitting passes and stroke is below, draw the fill after the stroke. if needs_separate_alignment_fill && wants_stroke_below { + log::info!("Entering needs_separate_alignment_fill && wants_stroke_below"); render.leaf_tag("path", |attributes| { attributes.push("d", path); let matrix = format_transform_matrix(element_transform); diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 01972d040c..2f5e01a6ab 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -57,6 +57,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); let contains_artboard = data.contains_artboard(); From 95e6a9211c39ea4d308f869628455e52e0a75670 Mon Sep 17 00:00:00 2001 From: seam0s Date: Tue, 14 Apr 2026 20:29:37 +0300 Subject: [PATCH 4/7] Refactor and add overlay support based on paint order --- .../document/overlays/utility_types_web.rs | 130 +++++++++++------- .../messages/tool/tool_messages/fill_tool.rs | 63 +-------- .../messages/tool/tool_messages/pen_tool.rs | 2 +- .../libraries/rendering/src/renderer.rs | 2 +- 4 files changed, 86 insertions(+), 111 deletions(-) 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 623b3b4450..f9bfc60285 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -17,7 +17,7 @@ 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::stroke::DashLengthsInput; -use graphene_std::vector::style::Stroke; +use graphene_std::vector::style::{PaintOrder, Stroke}; use graphene_std::vector::{PointId, SegmentId, Vector}; use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; use std::collections::HashMap; @@ -1031,69 +1031,97 @@ impl OverlayContext { /// Fills the area inside the path (with an optional pattern). Assumes `color` is in gamma space. /// Used by the Pen tool to show the path being closed and by the Fill tool to show the area to be filled with a pattern. - pub fn fill_path( - &mut self, - subpaths: impl Iterator>>, - transform: DAffine2, - stroke_transform: DAffine2, - color: &Color, - with_pattern: bool, - clear_stroke_part: bool, - stroke_width: Option, - ) { + pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { + self.draw_path_from_subpaths(subpaths, transform); + + let color_str = format!("#{:?}", color.to_rgba_hex_srgb()); + self.render_context.set_fill_style_str(&color_str.as_str()); + self.render_context.fill(); + } + + pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, layer_to_viewport: DAffine2, color: &Color, stroke: Option) { + // Render for elements with fill + // Render for elements with fill only + // Render for elements with fill and stroke + //----PaintOrder + //----StrokeAlign + self.render_context.save(); self.start_dpi_aware_transform(); - if with_pattern { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); - } else { - let color_str = format!("#{:?}", color.to_rgba_hex_srgb()); - self.render_context.set_fill_style_str(&color_str.as_str()); - } - // let stroke_transform = Some(stroke_transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); - let a = transform.matrix2.x_axis.x; - let b = transform.matrix2.y_axis.x; - let c = transform.matrix2.x_axis.y; - let d = transform.matrix2.y_axis.y; - let e = transform.translation.x; - let f = transform.translation.y; - self.render_context.transform(a, b, c, d, e, f); - self.draw_path_from_subpaths(subpaths, stroke_transform); - self.render_context.fill(); + 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 { layer_to_viewport }; + let element_transform = if has_real_stroke { layer_to_viewport * stroke.transform.inverse() } else { DAffine2::IDENTITY }; - // Make the stroke transparent and erase the fill area overlapping the stroke. - if clear_stroke_part { - self.render_context.set_line_width(stroke_width.unwrap_or(1.)); - self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); - self.render_context.set_stroke_style_str(&"#000000"); - self.render_context.stroke(); + let [a, b, c, d, e, f] = element_transform.to_cols_array(); + self.render_context.transform(a, b, c, d, e, f); + + self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + + match stroke.paint_order { + PaintOrder::StrokeAbove => { + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.fill(); + + // Make the stroke transparent and erase the fill area overlapping the stroke. + self.render_context.set_stroke_style_str(&"#000000"); + self.render_context.set_line_width(stroke.weight()); + self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); + self.render_context.stroke(); + } + PaintOrder::StrokeBelow => { + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.fill(); + } + } + } else { + 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(); } - pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, transform: DAffine2, overlay_stroke: &Stroke) { + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, layer_to_viewport: DAffine2, color: &Color, stroke: Option) { + // Render for elements with stroke + //----StrokeAlign + // Render for elements with stroke only + // Render for elements with stroke and fill + //----PaintOrder + self.render_context.save(); self.start_dpi_aware_transform(); - self.render_context - .set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); - self.render_context.set_line_width(overlay_stroke.weight); - self.render_context.set_line_cap(overlay_stroke.cap.html_canvas_name().as_str()); - self.render_context.set_line_join(overlay_stroke.join.html_canvas_name().as_str()); - self.render_context.set_miter_limit(overlay_stroke.join_miter_limit); - // let stroke_transform = Some(overlay_stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.).unwrap_or(DAffine2::IDENTITY); - // let stroke_transform = overlay_stroke.transform; - let a = transform.matrix2.x_axis.x; - let b = transform.matrix2.y_axis.x; - let c = transform.matrix2.x_axis.y; - let d = transform.matrix2.y_axis.y; - let e = transform.translation.x; - let f = transform.translation.y; - self.render_context.transform(a, b, c, d, e, f); - self.draw_path_from_subpaths(subpaths, overlay_stroke.transform); - self.render_context.stroke(); + 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 { layer_to_viewport }; + let element_transform = if has_real_stroke { layer_to_viewport * 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); + + self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + + 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); + match stroke.paint_order { + PaintOrder::StrokeAbove => { + self.render_context.stroke(); + } + PaintOrder::StrokeBelow => { + self.render_context.stroke(); + + self.render_context.set_fill_style_str(&"#000000"); + self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); + self.render_context.fill(); + } + } + } self.end_dpi_aware_transform(); self.render_context.restore(); diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index b46e073615..1762e042ef 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -2,12 +2,9 @@ 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::{self, NodeGraphLayer, get_stroke_width}; -use graph_craft::document::value::TaggedValue; -use graphene_std::NodeInputDecleration; use graphene_std::subpath::Subpath; use graphene_std::vector::PointId; -use graphene_std::vector::stroke::{AlignInput, CapInput, JoinInput, MiterLimitInput, PaintOrderInput}; -use graphene_std::vector::style::{Fill, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::Fill; use kurbo::ParamCurveNearest; #[derive(Default, ExtractField)] @@ -94,15 +91,6 @@ pub fn close_to_subpath(mouse_pos: DVec2, subpath: Subpath, stroke_widt } return is_close; - - // if let Some((segment_index, t)) = subpath.project(mouse_pos) { - // let nearest_point = subpath.evaluate(SubpathTValue::Parametric { segment_index, t }); - // // debug!("max_stroke_distance: {max_stroke_distance}"); - // // debug!("mouse-stroke distance: {:?}", (mouse_pos - nearest_point).length()); - // (mouse_pos - nearest_point).length_squared() <= max_stroke_distance - // } else { - // false - // } } const STROKE_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); @@ -157,16 +145,8 @@ impl Fsm for FillToolFsmState { let stroke_exists_and_visible = stroke_node.is_some_and(|stroke| document.network_interface.is_visible(&stroke, &[])); let stroke = vector_data.style.stroke(); - let has_real_stroke = stroke.filter(|stroke| stroke.weight() > 0.); - 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(document.metadata().transform_to_viewport(layer)); - let element_transform = set_stroke_transform - .map(|stroke_transform| document.metadata().transform_to_viewport(layer) * stroke_transform.inverse()) - .unwrap_or(DAffine2::IDENTITY); - let stroke_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); - let zoom: f64 = document.document_ptz.zoom(); - let modified_stroke_width = stroke_width; + let zoom = document.document_ptz.zoom(); let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); // Fill @@ -174,44 +154,11 @@ impl Fsm for FillToolFsmState { let fill_exists_and_visible = fill_node.is_some_and(|fill| document.network_interface.is_visible(&fill, &[])); subpaths = vector_data.stroke_bezier_paths(); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); if stroke_exists_and_visible && close_to_stroke { - let overlay_stroke = || { - let mut overlay_stroke = Stroke::new(Some(preview_color), modified_stroke_width); - overlay_stroke.transform = applied_stroke_transform; - - let align = graph_layer.find_input(&STROKE_ID, AlignInput::INDEX).unwrap(); - overlay_stroke.align = if let TaggedValue::StrokeAlign(align) = align { *align } else { StrokeAlign::default() }; - - let line_cap = graph_layer.find_input(&STROKE_ID, CapInput::INDEX).unwrap(); - overlay_stroke.cap = if let TaggedValue::StrokeCap(line_cap) = line_cap { *line_cap } else { StrokeCap::default() }; - - let line_join = graph_layer.find_input(&STROKE_ID, JoinInput::INDEX).unwrap(); - overlay_stroke.join = if let TaggedValue::StrokeJoin(line_join) = line_join { *line_join } else { StrokeJoin::default() }; - - let miter_limit = graph_layer.find_input(&STROKE_ID, MiterLimitInput::INDEX).unwrap(); - overlay_stroke.join_miter_limit = if let TaggedValue::F64(miter_limit) = miter_limit { *miter_limit } else { f64::default() }; - - let paint_order = graph_layer.find_input(&STROKE_ID, PaintOrderInput::INDEX).unwrap(); - overlay_stroke.paint_order = if let TaggedValue::PaintOrder(paint_order) = paint_order { - *paint_order - } else { - PaintOrder::default() - }; - - overlay_stroke - }; - - overlay_context.fill_stroke(subpaths, element_transform, &overlay_stroke()); + overlay_context.stroke_overlay(subpaths, layer_to_viewport, &preview_color, stroke); } else if fill_exists_and_visible { - overlay_context.fill_path( - subpaths, - element_transform, - applied_stroke_transform, - &preview_color, - true, - stroke_exists_and_visible, - Some(modified_stroke_width), - ); + overlay_context.fill_overlay(subpaths, layer_to_viewport, &preview_color, stroke); } } } diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs index a54eb6796b..294b94c271 100644 --- a/editor/src/messages/tool/tool_messages/pen_tool.rs +++ b/editor/src/messages/tool/tool_messages/pen_tool.rs @@ -1680,7 +1680,7 @@ impl Fsm for PenToolFsmState { .collect(); let fill_color = graphene_std::Color::from_rgb_str(COLOR_OVERLAY_BLUE.strip_prefix('#').unwrap()).unwrap().with_alpha(0.05); - overlay_context.fill_path(subpaths.iter(), transform, DAffine2::IDENTITY, &fill_color, false, false, None); + overlay_context.fill_path(subpaths.iter(), transform, &fill_color); } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 783479e70d..169c8a7c28 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -727,7 +727,7 @@ impl Render for Table { for row in self.iter() { let vector = &row.element; // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform - let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 1.); + let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.); 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(*row.transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); From 2adac3b34645e6620e10eba97d987fd5b4c20873 Mon Sep 17 00:00:00 2001 From: seam0s Date: Wed, 15 Apr 2026 23:51:16 +0300 Subject: [PATCH 5/7] Add stroke align support on fill overlays --- Cargo.toml | 2 + .../document/overlays/utility_types_web.rs | 119 +++++++++++++----- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1f206da26a..66ed2e647b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,6 +165,8 @@ web-sys = { version = "=0.3.77", features = [ "HtmlCanvasElement", "CanvasRenderingContext2d", "CanvasPattern", + "DomMatrix", + "SvgMatrix", "OffscreenCanvas", "OffscreenCanvasRenderingContext2d", "TextMetrics", 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 f9bfc60285..beef522c0b 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -14,15 +14,16 @@ use graphene_std::Color; use graphene_std::math::quad::Quad; use graphene_std::raster::curve; use graphene_std::subpath::Subpath; +use graphene_std::transform::Transform; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; use graphene_std::vector::stroke::DashLengthsInput; -use graphene_std::vector::style::{PaintOrder, Stroke}; +use graphene_std::vector::style::{PaintOrder, Stroke, StrokeAlign}; use graphene_std::vector::{PointId, SegmentId, Vector}; use kurbo::{self, Affine, CubicBez, ParamCurve, PathSeg}; use std::collections::HashMap; use wasm_bindgen::{JsCast, JsValue}; -use web_sys::{OffscreenCanvas, OffscreenCanvasRenderingContext2d}; +use web_sys::{DomMatrix, OffscreenCanvas, OffscreenCanvasRenderingContext2d, SvgMatrix}; pub type OverlayProvider = fn(OverlayContext) -> Message; @@ -997,7 +998,7 @@ impl OverlayContext { } /// Default canvas pattern used for filling stroke or fill of a path. - fn fill_canvas_pattern(&self, color: &Color) -> web_sys::CanvasPattern { + fn fill_canvas_pattern(&self, color: &Color, transform: DAffine2) -> web_sys::CanvasPattern { const PATTERN_WIDTH: usize = 4; const PATTERN_HEIGHT: usize = 4; @@ -1009,6 +1010,8 @@ impl OverlayContext { .expect("Failed to get canvas context") .dyn_into() .expect("Context should be a canvas 2d context"); + // let [a, b, c, d, e, f] = DAffine2::from_scale(zoom).inverse().to_cols_array(); + // pattern_context.set_transform(a, b, c, d, e, f); // 4x4 pixels, 4 components (RGBA) per pixel let mut data = [0_u8; 4 * PATTERN_WIDTH * PATTERN_HEIGHT]; @@ -1026,7 +1029,16 @@ 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(); - return self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap(); + + let pattern = self.render_context.create_pattern_with_offscreen_canvas(&pattern_canvas, "repeat").unwrap().unwrap(); + // let ctx_matrix = (|| { + // let dom_matrix = self.render_context.get_transform().unwrap(); + // dom_matrix. + // SvgMatrix::from(dom_matrix.to_json()) + // })(); + // pattern.set_transform(&ctx_matrix); + + return pattern; } /// Fills the area inside the path (with an optional pattern). Assumes `color` is in gamma space. @@ -1059,24 +1071,42 @@ impl OverlayContext { self.draw_path_from_subpaths(subpaths, applied_stroke_transform); - match stroke.paint_order { - PaintOrder::StrokeAbove => { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); - self.render_context.fill(); - - // Make the stroke transparent and erase the fill area overlapping the stroke. - self.render_context.set_stroke_style_str(&"#000000"); - self.render_context.set_line_width(stroke.weight()); - self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); - self.render_context.stroke(); + let do_fill = || { + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color, element_transform)); + 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()); } - PaintOrder::StrokeBelow => { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); - self.render_context.fill(); + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + do_fill(); + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + do_fill(); } } } else { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color, DAffine2::IDENTITY)); self.render_context.fill(); } @@ -1084,6 +1114,8 @@ impl OverlayContext { self.render_context.restore(); } + // 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>>, layer_to_viewport: DAffine2, color: &Color, stroke: Option) { // Render for elements with stroke //----StrokeAlign @@ -1104,21 +1136,44 @@ impl OverlayContext { self.draw_path_from_subpaths(subpaths, applied_stroke_transform); - 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); - match stroke.paint_order { - PaintOrder::StrokeAbove => { - self.render_context.stroke(); + let do_stroke = |stroke_weight: f64| { + self.render_context.set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(color, element_transform)); + 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) => { + // TODO: Use something aside from destination-in + do_stroke(stroke.weight() * 2.); + composite_mode("destination-in"); + do_fill(); } - PaintOrder::StrokeBelow => { - self.render_context.stroke(); - - self.render_context.set_fill_style_str(&"#000000"); - self.render_context.set_global_composite_operation("destination-out").expect("Failed to set global composite operation"); - self.render_context.fill(); + (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(); } } } From f412eb97be5eb4773aa05d6f7e7e086240d084dc Mon Sep 17 00:00:00 2001 From: seam0s Date: Sun, 19 Apr 2026 21:30:48 +0300 Subject: [PATCH 6/7] Fix overlay pattern scaling and ignore StrokeAlign on open subpaths --- .../document/overlays/utility_types_web.rs | 161 ++++++++++-------- .../messages/tool/tool_messages/fill_tool.rs | 3 +- 2 files changed, 91 insertions(+), 73 deletions(-) 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 beef522c0b..bdeff79a01 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -12,18 +12,16 @@ use core::f64::consts::{FRAC_PI_2, PI, TAU}; use glam::{DAffine2, DVec2}; use graphene_std::Color; use graphene_std::math::quad::Quad; -use graphene_std::raster::curve; use graphene_std::subpath::Subpath; -use graphene_std::transform::Transform; use graphene_std::vector::click_target::ClickTargetType; use graphene_std::vector::misc::{dvec2_to_point, point_to_dvec2}; -use graphene_std::vector::stroke::DashLengthsInput; 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::{DomMatrix, OffscreenCanvas, OffscreenCanvasRenderingContext2d, SvgMatrix}; +use web_sys::{CanvasPattern, DomMatrix, OffscreenCanvas, OffscreenCanvasRenderingContext2d}; pub type OverlayProvider = fn(OverlayContext) -> Message; @@ -910,18 +908,9 @@ impl OverlayContext { self.end_dpi_aware_transform(); } - pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, stroke_transform: DAffine2) { - // self.render_context.save(); - // self.start_dpi_aware_transform(); - - // let a = transform.matrix2.x_axis.x; - // let b = transform.matrix2.y_axis.x; - // let c = transform.matrix2.x_axis.y; - // let d = transform.matrix2.y_axis.y; - // let e = transform.translation.x; - // let f = transform.translation.y; - // self.render_context.transform(a, b, c, d, e, f); - // self.render_context.set_transform(a, b, c, d, e, f); + pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, stroke_transform: DAffine2) -> bool { + // Subpaths on a layer is considered "closed" only if all subpaths are closed. + let mut is_closed = true; self.render_context.begin_path(); for subpath in subpaths { let subpath = subpath.borrow().clone(); @@ -964,11 +953,11 @@ impl OverlayContext { if subpath.closed() { self.render_context.close_path(); + } else { + is_closed = false; } } - - // self.end_dpi_aware_transform(); - // self.render_context.restore(); + return is_closed; } /// Used by the Select tool to outline a path or a free point when selected or hovered. @@ -998,7 +987,7 @@ impl OverlayContext { } /// Default canvas pattern used for filling stroke or fill of a path. - fn fill_canvas_pattern(&self, color: &Color, transform: DAffine2) -> web_sys::CanvasPattern { + fn fill_canvas_pattern(&self, color: &Color) -> web_sys::CanvasPattern { const PATTERN_WIDTH: usize = 4; const PATTERN_HEIGHT: usize = 4; @@ -1010,8 +999,6 @@ impl OverlayContext { .expect("Failed to get canvas context") .dyn_into() .expect("Context should be a canvas 2d context"); - // let [a, b, c, d, e, f] = DAffine2::from_scale(zoom).inverse().to_cols_array(); - // pattern_context.set_transform(a, b, c, d, e, f); // 4x4 pixels, 4 components (RGBA) per pixel let mut data = [0_u8; 4 * PATTERN_WIDTH * PATTERN_HEIGHT]; @@ -1031,12 +1018,15 @@ impl OverlayContext { 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 ctx_matrix = (|| { - // let dom_matrix = self.render_context.get_transform().unwrap(); - // dom_matrix. - // SvgMatrix::from(dom_matrix.to_json()) - // })(); - // pattern.set_transform(&ctx_matrix); + 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>(()) + }; + set_pattern_transform(&pattern, &dom_matrix); return pattern; } @@ -1067,12 +1057,12 @@ impl OverlayContext { let element_transform = if has_real_stroke { layer_to_viewport * 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); + self.render_context.transform(a, b, c, d, e, f).expect("element_transform should be set to render stroke properly"); - self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + let is_closed = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); let do_fill = || { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color, element_transform)); + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); self.render_context.fill(); }; let do_stroke = |stroke_weight: f64| { @@ -1085,28 +1075,42 @@ impl OverlayContext { .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(); + + if is_closed { + 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(); + } } - // Paint order does not affect this - (StrokeAlign::Outside, _) => { - do_fill(); + } else { + match stroke.paint_order { + PaintOrder::StrokeAbove => { + do_fill(); + composite_mode("destination-out"); + do_stroke(stroke.weight()); + } + PaintOrder::StrokeBelow => { + do_fill(); + } } } } else { - self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color, DAffine2::IDENTITY)); + self.render_context.set_fill_style_canvas_pattern(&self.fill_canvas_pattern(color)); self.render_context.fill(); } @@ -1132,12 +1136,12 @@ impl OverlayContext { let element_transform = if has_real_stroke { layer_to_viewport * 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); + self.render_context.transform(a, b, c, d, e, f).expect("element_transform should be set to render stroke properly"); - self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + let is_closed = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); let do_stroke = |stroke_weight: f64| { - self.render_context.set_stroke_style_canvas_pattern(&self.fill_canvas_pattern(color, element_transform)); + 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()); @@ -1153,27 +1157,40 @@ impl OverlayContext { .set_global_composite_operation(composite_operation) .expect("Failed to set global composite operation"); }; - match (stroke.align, stroke.paint_order) { - (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { - // TODO: Use something aside from destination-in - do_stroke(stroke.weight() * 2.); - composite_mode("destination-in"); - do_fill(); - } - (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(); + + if is_closed { + 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(); + } } - // Paint order does not affect this - (StrokeAlign::Outside, _) => { - do_stroke(stroke.weight() * 2.); - composite_mode("destination-out"); - do_fill(); + } else { + match stroke.paint_order { + PaintOrder::StrokeAbove => { + do_stroke(stroke.weight()); + } + PaintOrder::StrokeBelow => { + do_stroke(stroke.weight()); + composite_mode("destination-out"); + do_fill(); + } } } } diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 1762e042ef..6b2907633d 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -77,8 +77,9 @@ impl ToolTransition for FillTool { } } -pub fn close_to_subpath(mouse_pos: DVec2, subpath: Subpath, stroke_width: f64, _zoom: f64, layer_to_viewport_transform: DAffine2) -> bool { +pub fn close_to_subpath(mouse_pos: DVec2, subpath: Subpath, stroke_width: f64, zoom: f64, layer_to_viewport_transform: DAffine2) -> bool { let mouse_pos = layer_to_viewport_transform.inverse().transform_point2(mouse_pos); + // WARN: Zoom multiplied into stroke width gives false positive when zoom in 1000x let max_stroke_distance = stroke_width; let subpath_bezpath = subpath.to_bezpath(); From feb6895284696f9d0fa2b1ef40a921627ac40e8e Mon Sep 17 00:00:00 2001 From: seam0s Date: Wed, 22 Apr 2026 13:01:53 +0300 Subject: [PATCH 7/7] Add StrokeAlign and PaintOrder support on stroke detection --- .../portfolio/document/overlays/mod.rs | 12 +- .../document/overlays/utility_types_native.rs | 76 +++++++--- .../document/overlays/utility_types_web.rs | 134 ++++++++--------- .../utility_types/document_metadata.rs | 1 - .../messages/tool/tool_messages/fill_tool.rs | 138 ++++++++++++------ 5 files changed, 208 insertions(+), 153 deletions(-) diff --git a/editor/src/messages/portfolio/document/overlays/mod.rs b/editor/src/messages/portfolio/document/overlays/mod.rs index 8643000c8c..4445dbfe84 100644 --- a/editor/src/messages/portfolio/document/overlays/mod.rs +++ b/editor/src/messages/portfolio/document/overlays/mod.rs @@ -3,15 +3,15 @@ mod overlays_message; mod overlays_message_handler; pub mod utility_functions; // Native (non‑wasm) -// #[cfg(not(target_family = "wasm"))] -// pub mod utility_types_native; -// #[cfg(not(target_family = "wasm"))] -// pub use utility_types_native as utility_types; +#[cfg(not(target_family = "wasm"))] +pub mod utility_types_native; +#[cfg(not(target_family = "wasm"))] +pub use utility_types_native as utility_types; // WebAssembly -// #[cfg(target_family = "wasm")] +#[cfg(target_family = "wasm")] pub mod utility_types_web; -// #[cfg(target_family = "wasm")] +#[cfg(target_family = "wasm")] pub use utility_types_web as utility_types; #[doc(inline)] 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 d4e6d75337..edcbe987c6 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_native.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_native.rs @@ -38,19 +38,32 @@ pub fn empty_provider() -> OverlayProvider { /// Types of overlays used by DocumentMessage to enable/disable the selected set of viewport overlays. #[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] 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` @@ -59,18 +72,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` @@ -79,18 +93,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, } } } @@ -152,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 + } } #[derive(serde::Serialize, serde::Deserialize, specta::Type)] @@ -389,14 +408,20 @@ impl OverlayContext { /// 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) { + pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { self.internal().fill_path(subpaths, transform, color); } - /// Fills the area inside the path with a pattern. Assumes `color` is in gamma space. + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is in gamma space. /// 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: &Color) { - self.internal().fill_path_pattern(subpaths, transform, color); + pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color, stroke: Option) { + self.internal().fill_overlay(subpaths, transform, color, stroke); + } + + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is in gamma space. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color, stroke: Option) { + self.internal().stroke_overlay(subpaths, transform, color, stroke); } pub fn text(&self, text: &str, font_color: &str, background_color: Option<&str>, transform: DAffine2, padding: f64, pivot: [Pivot; 2]) { @@ -1050,26 +1075,31 @@ impl OverlayContextInternal { /// 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) { + fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { let path = self.path_from_subpaths(subpaths, transform); - self.scene.fill(peniko::Fill::NonZero, self.get_transform(), Self::parse_color(color), None, &path); + let color_str = format!("#{}", color.to_rgba_hex_srgb()); + self.scene.fill(peniko::Fill::NonZero, self.get_transform(), Self::parse_color(color_str.as_str()), None, &path); } - /// Fills the area inside the path with a pattern. Assumes `color` is in gamma space. + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is in gamma space. /// Used by the fill tool to show the area to be filled. - fn fill_path_pattern(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { + fn fill_overlay(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color, stroke: Option) { let path = self.path_from_subpaths(subpaths, transform); let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(color)); self.scene.fill(peniko::Fill::NonZero, self.get_transform(), &brush, None, &path); } - pub fn fill_stroke(&mut self, subpaths: impl Iterator>>, transform: DAffine2, overlay_stroke: &Stroke) { + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is in gamma space. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn stroke_overlay(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color, stroke: Option) { let path = self.path_from_subpaths(subpaths, transform); - let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(&overlay_stroke.color.expect("Color should be set for fill_stroke()"))); - self.scene.stroke(&kurbo::Stroke::new(overlay_stroke.weight), self.get_transform(), &brush, None, &path); + if let Some(stroke) = stroke { + let brush = peniko::Brush::Image(self.fill_canvas_pattern_image(&color)); + self.scene.stroke(&kurbo::Stroke::new(stroke.weight), self.get_transform(), &brush, None, &path); + } } 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 bdeff79a01..aabfeac37e 100644 --- a/editor/src/messages/portfolio/document/overlays/utility_types_web.rs +++ b/editor/src/messages/portfolio/document/overlays/utility_types_web.rs @@ -61,7 +61,7 @@ pub enum OverlaysType { } #[derive(PartialEq, Copy, Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)] -#[serde(default = "OverlaysVisibilitySettings::default")] +#[serde(default)] pub struct OverlaysVisibilitySettings { pub all: bool, pub artboard_name: bool, @@ -910,7 +910,7 @@ impl OverlayContext { pub fn draw_path_from_subpaths(&mut self, subpaths: impl Iterator>>, stroke_transform: DAffine2) -> bool { // Subpaths on a layer is considered "closed" only if all subpaths are closed. - let mut is_closed = true; + let mut is_closed_on_all = true; self.render_context.begin_path(); for subpath in subpaths { let subpath = subpath.borrow().clone(); @@ -954,10 +954,10 @@ impl OverlayContext { if subpath.closed() { self.render_context.close_path(); } else { - is_closed = false; + is_closed_on_all = false; } } - return is_closed; + return is_closed_on_all; } /// Used by the Select tool to outline a path or a free point when selected or hovered. @@ -1031,17 +1031,18 @@ impl OverlayContext { return pattern; } - /// Fills the area inside the path (with an optional pattern). Assumes `color` is in gamma space. - /// Used by the Pen tool to show the path being closed and by the Fill tool to show the area to be filled with a pattern. + /// Used by the Pen tool to show the path being closed. pub fn fill_path(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color) { self.draw_path_from_subpaths(subpaths, transform); - let color_str = format!("#{:?}", color.to_rgba_hex_srgb()); - self.render_context.set_fill_style_str(&color_str.as_str()); + let color_str = format!("#{}", color.to_rgba_hex_srgb()); + self.render_context.set_fill_style_str(&color_str); self.render_context.fill(); } - pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, layer_to_viewport: DAffine2, color: &Color, stroke: Option) { + /// Fills the shape's fill region with a pattern of the given color. Assumes `color` is in gamma space. + /// https://www.w3schools.com/tags/canvas_globalcompositeoperation.asp + pub fn fill_overlay(&mut self, subpaths: impl Iterator>>, transform: DAffine2, color: &Color, stroke: Option) { // Render for elements with fill // Render for elements with fill only // Render for elements with fill and stroke @@ -1053,13 +1054,15 @@ impl OverlayContext { 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 { layer_to_viewport }; - 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 { 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"); - let is_closed = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + // For layers with open subpaths, stroke align is ignored and set to default + let is_closed_on_all = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + 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)); @@ -1076,37 +1079,24 @@ impl OverlayContext { .expect("Failed to set global composite operation"); }; - if is_closed { - 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(); - } + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + do_fill(); + composite_mode("destination-out"); + do_stroke(stroke.weight() * 2.); } - } else { - match stroke.paint_order { - PaintOrder::StrokeAbove => { - do_fill(); - composite_mode("destination-out"); - do_stroke(stroke.weight()); - } - PaintOrder::StrokeBelow => { - do_fill(); - } + (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 { @@ -1118,8 +1108,9 @@ impl OverlayContext { self.render_context.restore(); } - // WARN: Don't use source-in, destination-atop, destination-in, copy - // on the main canvas as it will erase the existing overlays + /// Fills the shape's stroke region with a pattern of the given color. Assumes `color` is in gamma space. + /// 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>>, layer_to_viewport: DAffine2, color: &Color, stroke: Option) { // Render for elements with stroke //----StrokeAlign @@ -1138,7 +1129,9 @@ impl OverlayContext { 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"); - let is_closed = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + // For layers with open subpaths, stroke align is ignored and set to default + let is_closed_on_all = self.draw_path_from_subpaths(subpaths, applied_stroke_transform); + 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)); @@ -1158,39 +1151,26 @@ impl OverlayContext { .expect("Failed to set global composite operation"); }; - if is_closed { - 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(); - } + 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.); } - } else { - match stroke.paint_order { - PaintOrder::StrokeAbove => { - do_stroke(stroke.weight()); - } - PaintOrder::StrokeBelow => { - do_stroke(stroke.weight()); - composite_mode("destination-out"); - do_fill(); - } + (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(); } } } 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 5173b77834..bc7b838eeb 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -10,7 +10,6 @@ use graphene_std::math::quad::Quad; use graphene_std::subpath; use graphene_std::transform::Footprint; use graphene_std::vector::click_target::{ClickTarget, ClickTargetType}; -use graphene_std::vector::style::Stroke; use graphene_std::vector::{PointId, Vector}; use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; diff --git a/editor/src/messages/tool/tool_messages/fill_tool.rs b/editor/src/messages/tool/tool_messages/fill_tool.rs index 6b2907633d..d7312b2f5f 100644 --- a/editor/src/messages/tool/tool_messages/fill_tool.rs +++ b/editor/src/messages/tool/tool_messages/fill_tool.rs @@ -1,11 +1,12 @@ 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::{self, NodeGraphLayer, get_stroke_width}; +use crate::messages::tool::common_functionality::graph_modification_utils::{self, NodeGraphLayer}; use graphene_std::subpath::Subpath; use graphene_std::vector::PointId; -use graphene_std::vector::style::Fill; -use kurbo::ParamCurveNearest; +use graphene_std::vector::misc::Tangent; +use graphene_std::vector::style::{Fill, PaintOrder, Stroke, StrokeAlign}; +use kurbo::{ParamCurve, ParamCurveNearest, Shape}; #[derive(Default, ExtractField)] pub struct FillTool { @@ -77,21 +78,64 @@ impl ToolTransition for FillTool { } } -pub fn close_to_subpath(mouse_pos: DVec2, subpath: Subpath, stroke_width: f64, zoom: f64, layer_to_viewport_transform: DAffine2) -> bool { - let mouse_pos = layer_to_viewport_transform.inverse().transform_point2(mouse_pos); - // WARN: Zoom multiplied into stroke width gives false positive when zoom in 1000x - let max_stroke_distance = stroke_width; - - let subpath_bezpath = subpath.to_bezpath(); - let mouse_point = kurbo::Point::new(mouse_pos.x, mouse_pos.y); - let mut is_close = false; - for seg in subpath_bezpath.segments() { - if seg.nearest(mouse_point, 0.01).distance_sq <= max_stroke_distance { - is_close = true; +pub fn near_to_subpath(mouse_pos: DVec2, subpath: Subpath, is_closed_on_all: bool, stroke: Option, layer_to_viewport_transform: DAffine2) -> bool { + let mut is_near = false; + + if let Some(stroke) = stroke { + let mouse_pos = layer_to_viewport_transform.inverse().transform_point2(mouse_pos); + // WARN: Zoom multiplied into stroke width gives false positive when zoom in 1000x + + let subpath_bezpath = subpath.to_bezpath(); + let mouse_point = kurbo::Point::new(mouse_pos.x, mouse_pos.y); + for seg in subpath_bezpath.segments() { + let nearest = seg.nearest(mouse_point, 0.01); + let is_inside_seg = if is_closed_on_all { + // Inside/outside detection for closed bezpaths + subpath_bezpath.contains(mouse_point) + } else { + // Inside/outside detection for open bezpaths + let tangent = seg.tangent_at(nearest.t); + let normal = kurbo::Vec2::new(-tangent.y, tangent.x); + let dir = (seg.eval(nearest.t) - mouse_point).normalize(); + + normal.dot(dir) >= 0.0 + }; + let stroke_align = if is_closed_on_all { stroke.align } else { StrokeAlign::Center }; + let mut max_stroke_distance = stroke.weight(); + match (stroke_align, stroke.paint_order) { + (StrokeAlign::Inside, PaintOrder::StrokeAbove) => { + if is_inside_seg { + max_stroke_distance *= 2.0; + } else { + max_stroke_distance = -1.0; + } + } + (StrokeAlign::Inside, PaintOrder::StrokeBelow) => { + max_stroke_distance = -1.0; + } + (StrokeAlign::Center, PaintOrder::StrokeAbove) => {} + (StrokeAlign::Center, PaintOrder::StrokeBelow) => { + if is_inside_seg { + max_stroke_distance = -1.0; + } + } + // Paint order does not affect this + (StrokeAlign::Outside, _) => { + if is_inside_seg { + max_stroke_distance = -1.0; + } else { + max_stroke_distance *= 2.0; + } + } + } + + if seg.nearest(mouse_point, 0.01).distance_sq <= max_stroke_distance { + is_near = true; + } } } - return is_close; + is_near } const STROKE_ID: DefinitionIdentifier = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER); @@ -136,31 +180,31 @@ impl Fsm for FillToolFsmState { return self; } // Get the layer the user is hovering - if let Some(layer) = document.click(input, viewport) { - if let Some(vector_data) = document.network_interface.vector_data_from_layer(layer) { - let mut subpaths = vector_data.stroke_bezier_paths(); - let graph_layer = graph_modification_utils::NodeGraphLayer::new(layer, &document.network_interface); - - // 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 stroke_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); - let zoom = document.document_ptz.zoom(); - let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); - - // 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(); - let layer_to_viewport = document.metadata().transform_to_viewport(layer); - if stroke_exists_and_visible && close_to_stroke { - overlay_context.stroke_overlay(subpaths, layer_to_viewport, &preview_color, stroke); - } else if fill_exists_and_visible { - overlay_context.fill_overlay(subpaths, layer_to_viewport, &preview_color, stroke); - } + 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); + + // 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(); + 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(), document.metadata().transform_to_viewport(layer))); + + // 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(); + let layer_to_viewport = document.metadata().transform_to_viewport(layer); + if stroke_exists_and_visible && near_to_stroke { + overlay_context.stroke_overlay(subpaths, layer_to_viewport, &preview_color, stroke); + } else if fill_exists_and_visible { + overlay_context.fill_overlay(subpaths, layer_to_viewport, &preview_color, stroke); } } @@ -192,22 +236,24 @@ impl Fsm for FillToolFsmState { responses.add(DocumentMessage::AddTransaction); - if let Some(vector_data) = document.network_interface.compute_modified_vector(layer) { - let mut subpaths = vector_data.stroke_bezier_paths(); + 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); // 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_width = get_stroke_width(layer, &document.network_interface).unwrap_or(1.0); - let zoom = document.document_ptz.zoom(); - let close_to_stroke = subpaths.any(|subpath| close_to_subpath(input.mouse.position, subpath, stroke_width, zoom, document.metadata().transform_to_viewport(layer))); + let stroke = vector_data.style.stroke(); + + let mut subpaths = vector_data.stroke_bezier_paths(); + 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(), document.metadata().transform_to_viewport(layer))); // 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 && close_to_stroke { + 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 });