Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Make Fill and Stroke apply in-the-moment, not as part of the final rendered element
Currently, both Fill and Stroke apply a single choice of color to their respective fields in the VectorData. This means you can overwrite one with a later one. But we would rather make it apply the moment the data arrives at each Fill or Stroke node, pushing to a Vec of styles (fills and strokes). Additionally, the transform of the shape in that moment should become stored in the stroke or fill (when gradients are used, that's relevant to fills, too). This lets you have multiple fills (useful when transparency is involved, especially with gradients) and strokes (allowing you to have multiple strokes on a single shape drawn on top of each other). It should also be possible to put the fill on top of the stroke, or vice versa, depending on the ordering of each node in the graph (and thus the Vec of styles on the VectorData).

This change also lets us apply subsquent transforms to the shape that skew the earlier-set fill, without requiring a Merge layer to get involved. See #1931.

This will allow us to eventually make a user-facing Style node which acts as a subgraph encapsulating various orders of Fill and Stroke nodes letting the user only worry about styling from the Properties panel UI (setting one fill and one stroke, choosing which is on top of which, etc.) without cluttering the graph with multiple nodes.

See also #1834.
The Transform node sets its GraphicGroup's final transform, but should only affect the data-so-far in the graph flow
If you stick a Transform node underneath a layer stack, the whole stack gets moved even though, intuitively, it's applying to no data in the stack so far. If you stick the Transform node/layer in the middle of a stack, it affects both those layers above and below it. Definitely feels wrong.

That's because it is changing the final resulting GraphicGroup's `transform` field, instead of the transform on the individual layers up to that point which make up the partial stack (the part underneath the transform node).

The tricky part is preserving the correct behavior for rendering strokes (or gradient fills) which can get stretched and skewed.

This relates to #1930 and #1834.


Discussion / hints:

Fix patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 8f80b27845..827bfeca95 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -33,7 +33,6 @@
 	},
 	// Rust Analyzer config
 	"rust-analyzer.cargo.allTargets": false,
-	"rust-analyzer.check.command": "clippy",
 	// ESLint config
 	"eslint.format.enable": true,
 	"eslint.workingDirectories": ["./frontend", "./website/other/bezier-rs-demos", "./website"],
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 9523c5701c..a6d8c1c40c 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
@@ -266,7 +266,7 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
 
 			let bounds_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
 			apply_usvg_fill(path.fill(), modify_inputs, transform * usvg_transform(node.abs_transform()), bounds_transform);
-			apply_usvg_stroke(path.stroke(), modify_inputs);
+			apply_usvg_stroke(path.stroke(), modify_inputs, transform * usvg_transform(node.abs_transform()));
 		}
 		usvg::Node::Image(_image) => {
 			warn!("Skip image")
@@ -279,7 +279,7 @@ fn import_usvg_node(modify_inputs: &mut ModifyInputsContext, node: &usvg::Node,
 	}
 }
 
-fn apply_usvg_stroke(stroke: Option<&usvg::Stroke>, modify_inputs: &mut ModifyInputsContext) {
+fn apply_usvg_stroke(stroke: Option<&usvg::Stroke>, modify_inputs: &mut ModifyInputsContext, transform: DAffine2) {
 	if let Some(stroke) = stroke {
 		if let usvg::Paint::Color(color) = &stroke.paint() {
 			modify_inputs.stroke_set(Stroke {
@@ -299,6 +299,7 @@ fn apply_usvg_stroke(stroke: Option<&usvg::Stroke>, modify_inputs: &mut ModifyIn
 					usvg::LineJoin::Bevel => LineJoin::Bevel,
 				},
 				line_join_miter_limit: stroke.miterlimit().get() as f64,
+				transform,
 			})
 		} else {
 			warn!("Skip non-solid stroke")
diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
index 5779ec1841..32315a7cb4 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
@@ -2597,7 +2597,28 @@ pub fn fill_properties(document_node: &DocumentNode, node_id: NodeId, _context:
 
 	let fill_type_switch = {
 		let mut row = vec![TextLabel::new("").widget_holder()];
-		add_blank_assist(&mut row);
+		match fill {
+			Fill::Solid(_) | Fill::None => add_blank_assist(&mut row),
+			Fill::Gradient(gradient) => {
+				let reverse_button = IconButton::new("Reverse", 24)
+					.tooltip("Reverse the gradient color stops")
+					.on_update(update_value(
+						{
+							let gradient = gradient.clone();
+							move |_| {
+								let mut gradient = gradient.clone();
+								gradient.stops = gradient.stops.reversed();
+								TaggedValue::Fill(Fill::Gradient(gradient))
+							}
+						},
+						node_id,
+						fill_index,
+					))
+					.widget_holder();
+				row.push(Separator::new(SeparatorType::Unrelated).widget_holder());
+				row.push(reverse_button);
+			}
+		}
 
 		let entries = vec![
 			RadioEntryData::new("solid")
@@ -2619,9 +2640,35 @@ pub fn fill_properties(document_node: &DocumentNode, node_id: NodeId, _context:
 	};
 	widgets.push(fill_type_switch);
 
-	if let Fill::Gradient(gradient) = fill {
+	if let Fill::Gradient(gradient) = fill.clone() {
 		let mut row = vec![TextLabel::new("").widget_holder()];
-		add_blank_assist(&mut row);
+		match gradient.gradient_type {
+			GradientType::Linear => add_blank_assist(&mut row),
+			GradientType::Radial => {
+				let orientation = if (gradient.end.x - gradient.start.x).abs() > f64::EPSILON * 1e6 {
+					gradient.end.x > gradient.start.x
+				} else {
+					(gradient.start.x + gradient.start.y) < (gradient.end.x + gradient.end.y)
+				};
+				let reverse_radial_gradient_button = IconButton::new(if orientation { "ReverseRadialGradientToRight" } else { "ReverseRadialGradientToLeft" }, 24)
+					.tooltip("Reverse which end the gradient radiates from")
+					.on_update(update_value(
+						{
+							let gradient = gradient.clone();
+							move |_| {
+								let mut gradient = gradient.clone();
+								std::mem::swap(&mut gradient.start, &mut gradient.end);
+								TaggedValue::Fill(Fill::Gradient(gradient))
+							}
+						},
+						node_id,
+						fill_index,
+					))
+					.widget_holder();
+				row.push(Separator::new(SeparatorType::Unrelated).widget_holder());
+				row.push(reverse_radial_gradient_button);
+			}
+		}
 
 		let new_gradient1 = gradient.clone();
 		let new_gradient2 = gradient.clone();
diff --git a/editor/src/messages/tool/tool_messages/ellipse_tool.rs b/editor/src/messages/tool/tool_messages/ellipse_tool.rs
index 7331e3f3a4..690d13f94b 100644
--- a/editor/src/messages/tool/tool_messages/ellipse_tool.rs
+++ b/editor/src/messages/tool/tool_messages/ellipse_tool.rs
@@ -206,16 +206,15 @@ impl Fsm for EllipseToolFsmState {
 				let nodes = vec![(NodeId(0), node)];
 
 				let layer = graph_modification_utils::new_custom(NodeId(generate_uuid()), nodes, document.new_layer_parent(true), responses);
-				tool_options.fill.apply_fill(layer, responses);
-				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
-				shape_data.layer = Some(layer);
-
 				responses.add(GraphOperationMessage::TransformSet {
 					layer,
 					transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position),
 					transform_in: TransformIn::Viewport,
 					skip_rerender: false,
 				});
+				tool_options.fill.apply_fill(layer, responses);
+				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
+				shape_data.layer = Some(layer);
 
 				EllipseToolFsmState::Drawing
 			}
diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs
index f9abd4d830..5fd2f313df 100644
--- a/editor/src/messages/tool/tool_messages/freehand_tool.rs
+++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs
@@ -233,7 +233,7 @@ impl Fsm for FreehandToolFsmState {
 				tool_options.stroke.apply_stroke(tool_data.weight, layer, responses);
 				tool_data.layer = Some(layer);
 
-				let transform = document.metadata().transform_to_viewport(layer);
+				let transform = document.metadata().transform_to_viewport(parent);
 				let position = transform.inverse().transform_point2(input.mouse.position);
 
 				extend_path_with_next_segment(tool_data, position, responses);
diff --git a/editor/src/messages/tool/tool_messages/gradient_tool.rs b/editor/src/messages/tool/tool_messages/gradient_tool.rs
index e90f60511e..02d04b89c3 100644
--- a/editor/src/messages/tool/tool_messages/gradient_tool.rs
+++ b/editor/src/messages/tool/tool_messages/gradient_tool.rs
@@ -86,11 +86,11 @@ impl LayoutHolder for GradientTool {
 		let gradient_type = RadioInput::new(vec![
 			RadioEntryData::new("linear")
 				.label("Linear")
-				.tooltip("Linear Gradient")
+				.tooltip("Linear gradient")
 				.on_update(move |_| GradientToolMessage::UpdateOptions(GradientOptionsUpdate::Type(GradientType::Linear)).into()),
 			RadioEntryData::new("radial")
 				.label("Radial")
-				.tooltip("Radial Gradient")
+				.tooltip("Radial gradient")
 				.on_update(move |_| GradientToolMessage::UpdateOptions(GradientOptionsUpdate::Type(GradientType::Radial)).into()),
 		])
 		.selected_index(Some((self.selected_gradient().unwrap_or(self.options.gradient_type) == GradientType::Radial) as u32))
diff --git a/editor/src/messages/tool/tool_messages/line_tool.rs b/editor/src/messages/tool/tool_messages/line_tool.rs
index e102542207..2943193ee8 100644
--- a/editor/src/messages/tool/tool_messages/line_tool.rs
+++ b/editor/src/messages/tool/tool_messages/line_tool.rs
@@ -188,15 +188,14 @@ impl Fsm for LineToolFsmState {
 				let nodes = vec![(NodeId(0), node)];
 
 				let layer = graph_modification_utils::new_custom(NodeId(generate_uuid()), nodes, document.new_layer_parent(false), responses);
-				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
-				tool_data.layer = Some(layer);
-
 				responses.add(GraphOperationMessage::TransformSet {
 					layer,
 					transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position),
 					transform_in: TransformIn::Viewport,
 					skip_rerender: false,
 				});
+				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
+				tool_data.layer = Some(layer);
 
 				tool_data.layer = Some(layer);
 				tool_data.weight = tool_options.line_weight;
diff --git a/editor/src/messages/tool/tool_messages/polygon_tool.rs b/editor/src/messages/tool/tool_messages/polygon_tool.rs
index d3b7c15542..96e076c78b 100644
--- a/editor/src/messages/tool/tool_messages/polygon_tool.rs
+++ b/editor/src/messages/tool/tool_messages/polygon_tool.rs
@@ -265,16 +265,15 @@ impl Fsm for PolygonToolFsmState {
 				let nodes = vec![(NodeId(0), node)];
 
 				let layer = graph_modification_utils::new_custom(NodeId(generate_uuid()), nodes, document.new_layer_parent(false), responses);
-				tool_options.fill.apply_fill(layer, responses);
-				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
-				polygon_data.layer = Some(layer);
-
 				responses.add(GraphOperationMessage::TransformSet {
 					layer,
 					transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position),
 					transform_in: TransformIn::Viewport,
 					skip_rerender: false,
 				});
+				tool_options.fill.apply_fill(layer, responses);
+				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
+				polygon_data.layer = Some(layer);
 
 				PolygonToolFsmState::Drawing
 			}
diff --git a/editor/src/messages/tool/tool_messages/rectangle_tool.rs b/editor/src/messages/tool/tool_messages/rectangle_tool.rs
index 672d88bece..7684c6b264 100644
--- a/editor/src/messages/tool/tool_messages/rectangle_tool.rs
+++ b/editor/src/messages/tool/tool_messages/rectangle_tool.rs
@@ -212,16 +212,15 @@ impl Fsm for RectangleToolFsmState {
 				let nodes = vec![(NodeId(0), node)];
 
 				let layer = graph_modification_utils::new_custom(NodeId(generate_uuid()), nodes, document.new_layer_parent(true), responses);
-				tool_options.fill.apply_fill(layer, responses);
-				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
-				shape_data.layer = Some(layer);
-
 				responses.add(GraphOperationMessage::TransformSet {
 					layer,
 					transform: DAffine2::from_scale_angle_translation(DVec2::ONE, 0., input.mouse.position),
 					transform_in: TransformIn::Viewport,
 					skip_rerender: false,
 				});
+				tool_options.fill.apply_fill(layer, responses);
+				tool_options.stroke.apply_stroke(tool_options.line_weight, layer, responses);
+				shape_data.layer = Some(layer);
 
 				RectangleToolFsmState::Drawing
 			}
diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs
index 028f5817e2..ed38fce7d5 100644
--- a/editor/src/node_graph_executor.rs
+++ b/editor/src/node_graph_executor.rs
@@ -284,7 +284,7 @@ impl NodeRuntime {
 
 		for monitor_node_path in &self.monitor_nodes {
 			// The monitor nodes are located within a document node, and are thus children in that network, so this gets the parent document node's ID
-			let Some(parent_network_node_id) = monitor_node_path.get(monitor_node_path.len() - 2).copied() else {
+			let Some(parent_network_node_id) = monitor_node_path.len().checked_sub(2).and_then(|index| monitor_node_path.get(index)).copied() else {
 				warn!("Monitor node has invalid node id");
 
 				continue;
diff --git a/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-left.svg b/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-left.svg
new file mode 100644
index 0000000000..c3a16b09e9
--- /dev/null
+++ b/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-left.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+	<path d="M8,7.5c0-1.38,1.12-2.5,2.5-2.5s2.5,1.12,2.5,2.5-1.12,2.5-2.5,2.5-2.5-1.12-2.5-2.5ZM9,7.5c0,.83.67,1.5,1.5,1.5s1.5-.67,1.5-1.5-.67-1.5-1.5-1.5-1.5.67-1.5,1.5z" />
+	<path d="M5,7.5c0-3.03,2.47-5.5,5.5-5.5s5.5,2.47,5.5,5.5-2.47,5.5-5.5,5.5-5.5-2.47-5.5-5.5ZM6,7.5c0,2.48,2.02,4.5,4.5,4.5s4.5-2.02,4.5-4.5-2.02-4.5-4.5-4.5-4.5,2.02-4.5,4.5z" />
+	<path d="M3,5v5S0,7.5,0,7.5l3-2.5z" />
+	<path d="M11,7.5h0c0,.28-.22.5-.5.5H2v-1h8.5c.28,0,.5.22.5.5z" />
+</svg>
diff --git a/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-right.svg b/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-right.svg
new file mode 100644
index 0000000000..7fde22c2af
--- /dev/null
+++ b/frontend/assets/icon-16px-solid/reverse-radial-gradient-to-right.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+	<path d="M5.5,10c-1.38,0-2.5-1.12-2.5-2.5s1.12-2.5,2.5-2.5,2.5,1.12,2.5,2.5-1.12,2.5-2.5,2.5ZM5.5,6c-.83,0-1.5.67-1.5,1.5s.67,1.5,1.5,1.5,1.5-.67,1.5-1.5-.67-1.5-1.5-1.5z" />
+	<path d="M5.5,13c-3.03,0-5.5-2.47-5.5-5.5S2.47,2,5.5,2s5.5,2.47,5.5,5.5-2.47,5.5-5.5,5.5ZM5.5,3C3.02,3,1,5.02,1,7.5s2.02,4.5,4.5,4.5,4.5-2.02,4.5-4.5S7.98,3,5.5,3z" />
+	<path d="M16,7.5l-3,2.5v-5l3,2.5z" />
+	<path d="M5.5,7h8.5v1H5.5c-.28,0-.5-.22-.5-.5h0c0-.28.22-.5.5-.5z" />
+</svg>
diff --git a/frontend/assets/icon-16px-solid/reverse.svg b/frontend/assets/icon-16px-solid/reverse.svg
new file mode 100644
index 0000000000..1fc2aec058
--- /dev/null
+++ b/frontend/assets/icon-16px-solid/reverse.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+	<polygon points="16,7.5 13,5 13,7 3,7 3,5 0,7.5 3,10 3,8 13,8 13,10 16,7.5" />
+</svg>
diff --git a/frontend/src/utility-functions/icons.ts b/frontend/src/utility-functions/icons.ts
index 0a30977679..f6d4f1561f 100644
--- a/frontend/src/utility-functions/icons.ts
+++ b/frontend/src/utility-functions/icons.ts
@@ -148,6 +148,9 @@ import Regenerate from "@graphite-frontend/assets/icon-16px-solid/regenerate.svg
 import Reload from "@graphite-frontend/assets/icon-16px-solid/reload.svg";
 import Rescale from "@graphite-frontend/assets/icon-16px-solid/rescale.svg";
 import Reset from "@graphite-frontend/assets/icon-16px-solid/reset.svg";
+import ReverseRadialGradientToLeft from "@graphite-frontend/assets/icon-16px-solid/reverse-radial-gradient-to-left.svg";
+import ReverseRadialGradientToRight from "@graphite-frontend/assets/icon-16px-solid/reverse-radial-gradient-to-right.svg";
+import Reverse from "@graphite-frontend/assets/icon-16px-solid/reverse.svg";
 import Settings from "@graphite-frontend/assets/icon-16px-solid/settings.svg";
 import Stack from "@graphite-frontend/assets/icon-16px-solid/stack.svg";
 import Trash from "@graphite-frontend/assets/icon-16px-solid/trash.svg";
@@ -223,6 +226,9 @@ const SOLID_16PX = {
 	Reload: { svg: Reload, size: 16 },
 	Rescale: { svg: Rescale, size: 16 },
 	Reset: { svg: Reset, size: 16 },
+	ReverseRadialGradientToLeft: { svg: ReverseRadialGradientToLeft, size: 16 },
+	ReverseRadialGradientToRight: { svg: ReverseRadialGradientToRight, size: 16 },
+	Reverse: { svg: Reverse, size: 16 },
 	Settings: { svg: Settings, size: 16 },
 	Stack: { svg: Stack, size: 16 },
 	Trash: { svg: Trash, size: 16 },
diff --git a/node-graph/gcore/src/graphic_element.rs b/node-graph/gcore/src/graphic_element.rs
index 3dd9eb6e9f..1b32aeafad 100644
--- a/node-graph/gcore/src/graphic_element.rs
+++ b/node-graph/gcore/src/graphic_element.rs
@@ -244,7 +244,15 @@ async fn construct_layer<Data: Into<GraphicElement> + Send>(
 ) -> GraphicGroup {
 	let graphic_element = self.graphic_element.eval(footprint).await;
 	let mut stack = self.stack.eval(footprint).await;
-	stack.push(graphic_element.into());
+	let mut element: GraphicElement = graphic_element.into();
+	if stack.transform.matrix2.determinant() != 0. {
+		*element.transform_mut() = stack.transform.inverse() * element.transform();
+	} else {
+		stack.clear();
+		stack.transform = DAffine2::IDENTITY;
+	}
+
+	stack.push(element);
 	stack
 }
 
diff --git a/node-graph/gcore/src/graphic_element/renderer.rs b/node-graph/gcore/src/graphic_element/renderer.rs
index 16f33d8ade..d31dd2a841 100644
--- a/node-graph/gcore/src/graphic_element/renderer.rs
+++ b/node-graph/gcore/src/graphic_element/renderer.rs
@@ -360,20 +360,27 @@ impl GraphicElementRendered for GraphicGroup {
 impl GraphicElementRendered for VectorData {
 	fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
 		let multiplied_transform = render.transform * self.transform;
+		let set_stroke_transform = self.style.stroke().map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.);
+		let applied_stroke_transform = set_stroke_transform.unwrap_or(self.transform);
+		let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse());
+		let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
 		let layer_bounds = self.bounding_box().unwrap_or_default();
-		let transformed_bounds = self.bounding_box_with_transform(multiplied_transform).unwrap_or_default();
+		let transformed_bounds = self.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
 
 		let mut path = String::new();
 		for subpath in self.stroke_bezier_paths() {
-			let _ = subpath.subpath_to_svg(&mut path, multiplied_transform);
+			let _ = subpath.subpath_to_svg(&mut path, applied_stroke_transform);
 		}
 
 		render.leaf_tag("path", |attributes| {
 			attributes.push("d", path);
+			let matrix = format_transform_matrix(element_transform);
+			attributes.push("transform", matrix);
 
+			let defs = &mut attributes.0.svg_defs;
 			let fill_and_stroke = self
 				.style
-				.render(render_params.view_mode, &mut attributes.0.svg_defs, multiplied_transform, layer_bounds, transformed_bounds);
+				.render(render_params.view_mode, defs, element_transform, applied_stroke_transform, layer_bounds, transformed_bounds);
 			attributes.push_val(fill_and_stroke);
 
 			if self.alpha_blending.opacity < 1. {
@@ -411,31 +418,31 @@ impl GraphicElementRendered for VectorData {
 	fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext) {
 		use crate::vector::style::GradientType;
 		use vello::peniko;
-
-		let transformed_bounds = GraphicElementRendered::bounding_box(self, transform).unwrap_or_default();
 		let mut layer = false;
+		let stroke_transform = self.style.stroke().map_or(DAffine2::IDENTITY, |stroke| stroke.transform);
+		let path_transform = (transform * self.transform) * stroke_transform.inverse();
+		let transformed_bounds = GraphicElementRendered::bounding_box(self, path_transform).unwrap_or_default();
 
 		if self.alpha_blending.opacity < 1. || self.alpha_blending.blend_mode != BlendMode::default() {
 			layer = true;
 			scene.push_layer(
 				peniko::BlendMode::new(self.alpha_blending.blend_mode.into(), peniko::Compose::SrcOver),
 				self.alpha_blending.opacity,
-				kurbo::Affine::IDENTITY,
+				kurbo::Affine::new((path_transform).to_cols_array()),
 				&kurbo::Rect::new(transformed_bounds[0].x, transformed_bounds[0].y, transformed_bounds[1].x, transformed_bounds[1].y),
 			);
 		}
 
-		let kurbo_transform = kurbo::Affine::new(transform.to_cols_array());
 		let to_point = |p: DVec2| kurbo::Point::new(p.x, p.y);
 		let mut path = kurbo::BezPath::new();
 		for subpath in self.stroke_bezier_paths() {
-			subpath.to_vello_path(self.transform, &mut path);
+			subpath.to_vello_path(stroke_transform, &mut path);
 		}
 
 		match self.style.fill() {
 			Fill::Solid(color) => {
 				let fill = peniko::Brush::Solid(peniko::Color::rgba(color.r() as f64, color.g() as f64, color.b() as f64, color.a() as f64));
-				scene.fill(peniko::Fill::NonZero, kurbo_transform, &fill, None, &path);
+				scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(path_transform.to_cols_array()), &fill, None, &path);
 			}
 			Fill::Gradient(gradient) => {
 				let mut stops = peniko::ColorStops::new();
@@ -472,7 +479,7 @@ impl GraphicElementRendered for VectorData {
 					stops,
 					..Default::default()
 				});
-				scene.fill(peniko::Fill::NonZero, kurbo_transform, &fill, None, &path);
+				scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(path_transform.to_cols_array()), &fill, None, &path);
 			}
 			Fill::None => (),
 		};
@@ -504,7 +511,7 @@ impl GraphicElementRendered for VectorData {
 				dash_offset: stroke.dash_offset,
 			};
 			if stroke.width > 0. {
-				scene.stroke(&stroke, kurbo_transform, color, None, &path);
+				scene.stroke(&stroke, kurbo::Affine::new(path_transform.to_cols_array()), color, None, &path);
 			}
 		}
 		if layer {
@@ -596,7 +603,8 @@ impl GraphicElementRendered for Artboard {
 
 		// Render background
 		let color = peniko::Color::rgba(self.background.r() as f64, self.background.g() as f64, self.background.b() as f64, self.background.a() as f64);
-		let rect = kurbo::Rect::new(self.location.x as f64, self.location.y as f64, self.dimensions.x as f64, self.dimensions.y as f64);
+		let [a, b] = [self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()];
+		let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
 		let blend_mode = peniko::BlendMode::new(peniko::Mix::Clip, peniko::Compose::SrcOver);
 
 		scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect);
@@ -606,7 +614,8 @@ impl GraphicElementRendered for Artboard {
 		if self.clip {
 			scene.push_layer(blend_mode, 1., kurbo::Affine::new(transform.to_cols_array()), &rect);
 		}
-		self.graphic_group.render_to_vello(scene, transform, context);
+		let child_transform = transform * DAffine2::from_translation(self.location.as_dvec2()) * self.graphic_group.transform;
+		self.graphic_group.render_to_vello(scene, child_transform, context);
 		if self.clip {
 			scene.pop_layer();
 		}
@@ -614,8 +623,10 @@ impl GraphicElementRendered for Artboard {
 
 	fn add_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
 		let mut subpath = Subpath::new_rect(DVec2::ZERO, self.dimensions.as_dvec2());
-		subpath.apply_transform(self.graphic_group.transform.inverse());
-		click_targets.push(ClickTarget::new(subpath, 0.));
+		if self.graphic_group.transform.matrix2.determinant() != 0. {
+			subpath.apply_transform(self.graphic_group.transform.inverse());
+			click_targets.push(ClickTarget::new(subpath, 0.));
+		}
 	}
 
 	fn contains_artboard(&self) -> bool {
diff --git a/node-graph/gcore/src/vector/style.rs b/node-graph/gcore/src/vector/style.rs
index a1e24b72b4..fa8c92a7f7 100644
--- a/node-graph/gcore/src/vector/style.rs
+++ b/node-graph/gcore/src/vector/style.rs
@@ -145,10 +145,9 @@ impl Gradient {
 	}
 
 	/// Adds the gradient def through mutating the first argument, returning the gradient ID.
-	fn render_defs(&self, svg_defs: &mut String, multiplied_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> u64 {
+	fn render_defs(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> u64 {
 		let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
-		let transformed_bound_transform = DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]);
-		let updated_transform = multiplied_transform * bound_transform;
+		let transformed_bound_transform = element_transform * DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]);
 
 		let mut stop = String::new();
 		for (position, color) in self.stops.0.iter() {
@@ -168,7 +167,7 @@ impl Gradient {
 		} else {
 			DAffine2::IDENTITY // Ignore if the transform cannot be inverted (the bounds are zero). See issue #1944.
 		};
-		let mod_points = updated_transform;
+		let mod_points = element_transform * stroke_transform * bound_transform;
 
 		let start = mod_points.transform_point2(self.start);
 		let end = mod_points.transform_point2(self.end);
@@ -301,7 +300,7 @@ impl Fill {
 	}
 
 	/// Renders the fill, adding necessary defs through mutating the first argument.
-	pub fn render(&self, svg_defs: &mut String, multiplied_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
+	pub fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
 		match self {
 			Self::None => r#" fill="none""#.to_string(),
 			Self::Solid(color) => {
@@ -312,7 +311,7 @@ impl Fill {
 				result
 			}
 			Self::Gradient(gradient) => {
-				let gradient_id = gradient.render_defs(svg_defs, multiplied_transform, bounds, transformed_bounds);
+				let gradient_id = gradient.render_defs(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
 				format!(r##" fill="url('#{gradient_id}')""##)
 			}
 		}
@@ -450,6 +449,10 @@ impl Display for LineJoin {
 	}
 }
 
+fn daffine2_identity() -> DAffine2 {
+	DAffine2::IDENTITY
+}
+
 #[repr(C)]
 #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny, specta::Type)]
 pub struct Stroke {
@@ -462,6 +465,8 @@ pub struct Stroke {
 	pub line_cap: LineCap,
 	pub line_join: LineJoin,
 	pub line_join_miter_limit: f64,
+	#[serde(default = "daffine2_identity")]
+	pub transform: DAffine2,
 }
 
 impl core::hash::Hash for Stroke {
@@ -487,6 +492,7 @@ impl Stroke {
 			line_cap: LineCap::Butt,
 			line_join: LineJoin::Miter,
 			line_join_miter_limit: 4.,
+			transform: DAffine2::IDENTITY,
 		}
 	}
 
@@ -499,6 +505,10 @@ impl Stroke {
 			line_cap: if time < 0.5 { self.line_cap } else { other.line_cap },
 			line_join: if time < 0.5 { self.line_join } else { other.line_join },
 			line_join_miter_limit: self.line_join_miter_limit + (other.line_join_miter_limit - self.line_join_miter_limit) * time,
+			transform: DAffine2::from_mat2_translation(
+				time * self.transform.matrix2 + (1. - time) * other.transform.matrix2,
+				self.transform.translation * time + other.transform.translation * (1. - time),
+			),
 		}
 	}
 
@@ -635,6 +645,7 @@ impl Default for Stroke {
 			line_cap: LineCap::Butt,
 			line_join: LineJoin::Miter,
 			line_join_miter_limit: 4.,
+			transform: DAffine2::IDENTITY,
 		}
 	}
 }
@@ -787,15 +798,15 @@ impl PathStyle {
 	}
 
 	/// Renders the shape's fill and stroke attributes as a string with them concatenated together.
-	pub fn render(&self, view_mode: ViewMode, svg_defs: &mut String, multiplied_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
+	pub fn render(&self, view_mode: ViewMode, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String {
 		match view_mode {
 			ViewMode::Outline => {
-				let fill_attribute = Fill::None.render(svg_defs, multiplied_transform, bounds, transformed_bounds);
+				let fill_attribute = Fill::None.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
 				let stroke_attribute = Stroke::new(Some(LAYER_OUTLINE_STROKE_COLOR), LAYER_OUTLINE_STROKE_WEIGHT).render();
 				format!("{fill_attribute}{stroke_attribute}")
 			}
 			_ => {
-				let fill_attribute = self.fill.render(svg_defs, multiplied_transform, bounds, transformed_bounds);
+				let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds);
 				let stroke_attribute = self.stroke.as_ref().map(|stroke| stroke.render()).unwrap_or_default();
 				format!("{fill_attribute}{stroke_attribute}")
 			}
diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs
index 556499bf11..cd1061655f 100644
--- a/node-graph/gcore/src/vector/vector_nodes.rs
+++ b/node-graph/gcore/src/vector/vector_nodes.rs
@@ -156,6 +156,7 @@ fn set_vector_data_stroke(
 		line_cap,
 		line_join,
 		line_join_miter_limit: miter_limit,
+		transform: vector_data.transform,
 	});
 	vector_data
 }
diff --git a/node-graph/graph-craft/benches/compile_demo_art.rs b/node-graph/graph-craft/benches/compile_demo_art.rs
index c51e49d5dd..8ab08406ef 100644
--- a/node-graph/graph-craft/benches/compile_demo_art.rs
+++ b/node-graph/graph-craft/benches/compile_demo_art.rs
@@ -1,9 +1,11 @@
+use graph_craft::document::NodeNetwork;
 #[cfg(any(feature = "criterion", feature = "iai"))]
-use graph_craft::{document::NodeNetwork, graphene_compiler::Compiler, proto::ProtoNetwork};
+use graph_craft::graphene_compiler::Compiler;
+#[cfg(any(feature = "criterion", feature = "iai"))]
+use graph_craft::proto::ProtoNetwork;
 
 #[cfg(feature = "criterion")]
 use criterion::{black_box, criterion_group, criterion_main, Criterion};
-
 #[cfg(all(not(feature = "criterion"), feature = "iai"))]
 use iai_callgrind::{black_box, library_benchmark, library_benchmark_group, main};
 
@@ -18,7 +20,6 @@ fn compile(network: NodeNetwork) -> ProtoNetwork {
 	let compiler = Compiler {};
 	compiler.compile_single(network).unwrap()
 }
-
 #[cfg(all(not(feature = "criterion"), feature = "iai"))]
 fn load_from_name(name: &str) -> NodeNetwork {
 	let content = std::fs::read(&format!("../../demo-artwork/{name}.graphite")).expect("failed to read file");
@@ -27,7 +28,6 @@ fn load_from_name(name: &str) -> NodeNetwork {
 	black_box(compile(black_box(network)));
 	load_network(content)
 }
-
 #[cfg(feature = "criterion")]
 fn compile_to_proto(c: &mut Criterion) {
 	let artworks = glob::glob("../../demo-artwork/*.graphite").expect("failed to read glob pattern");
@@ -42,17 +42,15 @@ fn compile_to_proto(c: &mut Criterion) {
 
 #[cfg_attr(all(feature = "iai", not(feature = "criterion")), library_benchmark)]
 #[cfg_attr(all(feature = "iai", not(feature="criterion")), benches::with_setup(args = ["isometric-fountain", "painted-dreams", "procedural-string-lights", "red-dress", "valley-of-spires"], setup = load_from_name))]
-#[cfg(all(not(feature = "criterion"), feature = "iai"))]
-pub fn iai_compile_to_proto(input: NodeNetwork) {
-	black_box(compile(input));
+pub fn iai_compile_to_proto(_input: NodeNetwork) {
+	#[cfg(all(feature = "iai", not(feature = "criterion")))]
+	black_box(compile(_input));
 }
 
 #[cfg(feature = "criterion")]
 criterion_group!(benches, compile_to_proto);
-
 #[cfg(feature = "criterion")]
 criterion_main!(benches);
-
 #[cfg(all(not(feature = "criterion"), feature = "iai"))]
 library_benchmark_group!(name = compile_group; benchmarks = iai_compile_to_proto);
 


Test patch: