From d90a49aa423d0a6c94a7479547e605f33401b130 Mon Sep 17 00:00:00 2001 From: YohYamasaki <74522538+YohYamasaki@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:50:27 +0200 Subject: [PATCH 1/5] Add renderer support for painting vector with "fill" and "stroke" attributes with graphic List types (#4111) * Add conversion from Fill to Table * Refactor Vector vello renderer for Gradient / Color # Conflicts: # node-graph/libraries/rendering/src/renderer.rs * Refactor Vector SVG renderer for Gradient / Color * Fix conflicts * Add basic clipping-based fill for SVG rendering * Use Cow to avoid cloning graphic list for fill * Cleanup for Cow usage * format code * Use `` instead of `` for clip This simplifies the future implementation of clipping-based rendering for strokes, as the stroke does not support the use of a clip path but rather paint sources from a paint server. * Move svg pattern rendering function to RenderExt * Fix comment * Fix empty fill list rendering as default black Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Move opaque check function to Graphic impl * Add color converter and debug node to use graphic * WIP: Use List to render Color & Gradient * Use `Arc>` for vector_data metadata This exposes List's attributes to message handlers, enabling them to access the necessary attribute data such as ATTR_STROKE_PAINT_GRAPHIC as `Fill` and `Stroke` will not have paint information in the future. * Recurse opacity checks on nested `Graphic` Also extracts `fill_graphic_list_at` / `stroke_paint_graphic_list_at` to share the row-attribute lookup across the existing call sites. * Fix fill and stroke visibility check degradation * Fix clipping based stroke paint positioning * Refactor vello renderer for stroke to use graphic * Reduce `Fill` / `Stroke.color` to `List` allocations * Revert "Use `Arc>` for vector_data metadata" This reverts commit 4285243a5dd9c53be82982a9a02ed02861ff4a8c * Expose paint row attributes as dedicated metadata for vectors Add `fill_attributes` / `stroke_paint_attributes` to `DocumentMetadata` so the `ExpandFillStrokeOnSelectedLayers` handler can read row paint visibility without exposing entire `List`. * Fix transparency check to consider fill opacity * Fix consistency of gradient placement for SVG stroke * Rename `stroke_paint_..` to `stroke_..` * Remove debug nodes * Allow to use any graphic type without casting * Rename `fill_graphic` / `stroke_graphic` to `fill` / `stroke` * Fix SVG pattern placement when stroke transform differs from item transform * Fix click target fill check for empty list in graphic * Fix blank fill/stroke attribute masking legacy style * Fix SVG's paint order trick for vector/raster fills * Add zero-division guard for pattern wraparound prevention * Code review --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: Keavon Chambers --- .../portfolio/document/document_message.rs | 12 + .../document/document_message_handler.rs | 45 +- .../utility_types/document_metadata.rs | 8 + .../utility_types/network_interface.rs | 12 + editor/src/node_graph_executor.rs | 4 + .../interpreted-executor/src/node_registry.rs | 3 + node-graph/libraries/core-types/src/list.rs | 22 + node-graph/libraries/core-types/src/ops.rs | 4 +- .../libraries/graphic-types/src/artboard.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 322 ++++++++++- .../libraries/rendering/src/render_ext.rs | 226 ++++++-- .../libraries/rendering/src/renderer.rs | 528 ++++++++++++------ .../libraries/vector-types/src/gradient.rs | 30 + .../vector-types/src/vector/style.rs | 2 +- node-graph/nodes/vector/src/vector_nodes.rs | 17 +- 15 files changed, 977 insertions(+), 260 deletions(-) diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 22ef7c6385..0274a05db1 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -12,6 +12,8 @@ use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; use graphene_std::Color; +use graphene_std::Graphic; +use graphene_std::list::List; use graphene_std::raster::BlendMode; use graphene_std::raster::Image; use graphene_std::transform::Footprint; @@ -235,6 +237,16 @@ pub enum DocumentMessage { UpdateVectorData { vector_data: HashMap>, }, + // `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it. + // `UpdateFillAttributes` and `UpdateStrokeAttributes` are produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point. + #[serde(skip)] + UpdateFillAttributes { + fill_attributes: HashMap>>, + }, + #[serde(skip)] + UpdateStrokeAttributes { + stroke_attributes: HashMap>>, + }, Undo, UngroupSelectedLayers, UngroupLayer { diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 7ffceb9f5a..799fa74631 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -1404,6 +1404,34 @@ impl MessageHandler> for DocumentMes .collect(); self.network_interface.update_vector_data(layer_vector_data); } + DocumentMessage::UpdateFillAttributes { fill_attributes } => { + // Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers + let layer_fill_attributes = fill_attributes + .into_iter() + .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) + .filter_map(|(node_id, attrs)| { + self.network_interface.is_layer(&node_id, &[]).then(|| { + let layer = LayerNodeIdentifier::new(node_id, &self.network_interface); + (layer, attrs) + }) + }) + .collect(); + self.network_interface.update_fill_attributes(layer_fill_attributes); + } + DocumentMessage::UpdateStrokeAttributes { stroke_attributes } => { + // Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers + let layer_stroke_attributes = stroke_attributes + .into_iter() + .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) + .filter_map(|(node_id, attrs)| { + self.network_interface.is_layer(&node_id, &[]).then(|| { + let layer = LayerNodeIdentifier::new(node_id, &self.network_interface); + (layer, attrs) + }) + }) + .collect(); + self.network_interface.update_stroke_attributes(layer_stroke_attributes); + } DocumentMessage::Undo => { if self.network_interface.transaction_status() != TransactionStatus::Finished { return; @@ -2486,10 +2514,23 @@ impl DocumentMessageHandler { continue; }; - let has_fill = !matches!(style.fill, Fill::None); + let fill_graphic_list = self.network_interface.document_metadata().layer_fill_attributes.get(&layer); + let stroke_graphic_list = self.network_interface.document_metadata().layer_stroke_attributes.get(&layer); + + let has_fill = if let Some(list) = fill_graphic_list { + list.element(0).is_some() + } else { + !matches!(style.fill, Fill::None) + }; // `style.stroke` is `Some` whenever a `Stroke` node is in the chain, even with weight 0 or a transparent color. // So `is_some()` would treat invisibly-stroked fill-only layers as having a stroke. - let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()); + // `ATTR_STROKE` is the source of truth when set; fall back to `style.stroke.color` only when no attribute is present. + let stroke_visible = if let Some(list) = stroke_graphic_list { + list.element(0).is_some_and(|g| !g.is_fully_transparent()) + } else { + style.stroke.as_ref().and_then(|s| s.color()).is_some_and(|c| c.a() != 0.) + }; + let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()) && stroke_visible; // No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip. if !has_stroke { diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index 496fa909c8..2676f75dc1 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -6,6 +6,8 @@ use crate::messages::portfolio::document::utility_types::network_interface::Flow use crate::messages::tool::common_functionality::graph_modification_utils; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; +use graphene_std::Graphic; +use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath; use graphene_std::transform::Footprint; @@ -39,6 +41,12 @@ pub struct DocumentMetadata { /// Vector data keyed by layer ID, used as fallback when no Path node exists. /// This provides accurate SegmentIds for layers without explicit Path nodes. pub layer_vector_data: HashMap>, + /// Per-layer `ATTR_FILL` attribute, exposed so message handlers can read paint + /// information that lives on the list. + pub layer_fill_attributes: HashMap>>, + /// Per-layer `ATTR_STROKE` attribute, exposed so message handlers can read + /// stroke paint information that lives on the list. + pub layer_stroke_attributes: HashMap>>, /// Transform from document space to viewport space. pub document_to_viewport: DAffine2, } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 0a6c29ed1a..0ac69d677a 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -24,6 +24,8 @@ use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graphene_std::ContextDependencies; +use graphene_std::Graphic; +use graphene_std::list::List; use graphene_std::math::quad::Quad; use graphene_std::subpath::Subpath; use graphene_std::transform::Footprint; @@ -3401,6 +3403,16 @@ impl NodeNetworkInterface { pub fn update_vector_data(&mut self, new_layer_vector_data: HashMap>) { self.document_metadata.layer_vector_data = new_layer_vector_data; } + + /// Update the per-layer `ATTR_FILL` snapshot. + pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap>>) { + self.document_metadata.layer_fill_attributes = new_layer_fill_attributes; + } + + /// Update the per-layer `ATTR_STROKE` snapshot. + pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap>>) { + self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes; + } } // Public mutable methods diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index ae089b9041..4e5182736c 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -446,6 +446,8 @@ impl NodeGraphExecutor { text_frames, clip_targets, vector_data, + fill_attributes, + stroke_attributes, backgrounds: _, } = render_output.metadata; @@ -460,6 +462,8 @@ impl NodeGraphExecutor { responses.add(DocumentMessage::UpdateTextFrames { text_frames }); responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); responses.add(DocumentMessage::UpdateVectorData { vector_data }); + responses.add(DocumentMessage::UpdateFillAttributes { fill_attributes }); + responses.add(DocumentMessage::UpdateStrokeAttributes { stroke_attributes }); responses.add(DocumentMessage::RenderScrollbars); responses.add(DocumentMessage::RenderRulers); responses.add(OverlaysMessage::Draw); diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index bb5c8d4ce5..7504ce0777 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -88,6 +88,9 @@ fn node_registry() -> HashMap, to: AttributeValueDyn), convert_node!(from: List, to: AttributeValueDyn), convert_node!(from: List, to: AttributeValueDyn), + convert_node!(from: List, to: AttributeValueDyn), + convert_node!(from: List>, to: AttributeValueDyn), + convert_node!(from: List>, to: AttributeValueDyn), convert_node!(from: List, to: AttributeValueDyn), // into_node!(from: List>, to: List>), #[cfg(feature = "gpu")] diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 76d1b75ede..976d6ca7f5 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -77,6 +77,12 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; +/// Vector graphics object's filled area paint, of type List where T is any graphic type. +pub const ATTR_FILL: &str = "fill"; + +/// Vector graphics object's stroke paint, of type List where T is any graphic type. +pub const ATTR_STROKE: &str = "stroke"; + // =========================== // Implicit attribute defaults // =========================== @@ -655,6 +661,22 @@ impl ItemAttributeValues { } }) } + + /// Moves the attribute at `from_key` to `to_key`. + /// Does nothing if `from_key` is absent, overwrites any existing `to_key`. + pub fn rename(&mut self, from_key: &str, to_key: impl Into) { + let Some(pos) = self.0.iter().position(|(k, _)| k == from_key) else { return }; + let (_, value) = self.0.remove(pos); + + let to_key = to_key.into(); + for (existing_key, existing_value) in &mut self.0 { + if *existing_key == to_key { + *existing_value = value; + return; + } + } + self.0.push((to_key, value)); + } } // ========== diff --git a/node-graph/libraries/core-types/src/ops.rs b/node-graph/libraries/core-types/src/ops.rs index ed83196c86..5ab39e35bd 100644 --- a/node-graph/libraries/core-types/src/ops.rs +++ b/node-graph/libraries/core-types/src/ops.rs @@ -56,7 +56,7 @@ impl Convert for T { } pub trait ListConvert { - fn convert_row(self) -> U; + fn convert_item(self) -> U; } impl + Send> Convert, ()> for List { @@ -65,7 +65,7 @@ impl + Send> Convert, ()> for List { .into_iter() .map(|row| { let (element, attributes) = row.into_parts(); - Item::from_parts(element.convert_row(), attributes) + Item::from_parts(element.convert_item(), attributes) }) .collect(); list diff --git a/node-graph/libraries/graphic-types/src/artboard.rs b/node-graph/libraries/graphic-types/src/artboard.rs index ec36e1fe20..f59362bc74 100644 --- a/node-graph/libraries/graphic-types/src/artboard.rs +++ b/node-graph/libraries/graphic-types/src/artboard.rs @@ -8,7 +8,7 @@ use glam::DAffine2; /// Nominal wrapper around `List` representing a single artboard's content. /// -/// Per-artboard metadata (location, dimensions, background, clip) lives as row attributes on the +/// Per-artboard metadata (location, dimensions, background, clip) lives as attributes on the /// enclosing `List`, not as fields here. This keeps `Artboard` a pure type-system boundary /// that prevents arbitrary `List>>` nesting. #[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)] diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 13bd8b7150..e70153a389 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -1,17 +1,17 @@ use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; -use core_types::list::List; +use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List}; use core_types::ops::ListConvert; use core_types::render_complexity::RenderComplexity; use core_types::uuid::NodeId; -use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use dyn_any::DynAny; use glam::DAffine2; use raster_types::{CPU, GPU, Raster}; +use std::borrow::Cow; use vector_types::GradientStops; -// use vector_types::Vector; - pub use vector_types::Vector; +use vector_types::vector::style::Fill; /// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. #[derive(Clone, Debug, CacheHash, PartialEq, DynAny)] @@ -107,21 +107,21 @@ impl From> for Graphic { /// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity. fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) -> Option>) -> List { fn flatten_recursive(output: &mut List, current_graphic_list: List, extract_variant: fn(Graphic) -> Option>) { - for current_graphic_row in current_graphic_list.into_iter() { + for current_graphic_item in current_graphic_list.into_iter() { // Whether the parent carries each attribute: a structural fact (column presence), never a value comparison. // Flattening composes a parent attribute onto its children only when the parent has it, // so an absent parent attribute never invents a column the children didn't already have. - let parent_has_transform = current_graphic_row.attribute::(ATTR_TRANSFORM).is_some(); - let parent_has_opacity = current_graphic_row.attribute::(ATTR_OPACITY).is_some(); - let parent_has_fill = current_graphic_row.attribute::(ATTR_OPACITY_FILL).is_some(); - let parent_has_layer_path = current_graphic_row.attribute::>(ATTR_EDITOR_LAYER_PATH).is_some(); + let parent_has_transform = current_graphic_item.attribute::(ATTR_TRANSFORM).is_some(); + let parent_has_opacity = current_graphic_item.attribute::(ATTR_OPACITY).is_some(); + let parent_has_fill = current_graphic_item.attribute::(ATTR_OPACITY_FILL).is_some(); + let parent_has_layer_path = current_graphic_item.attribute::>(ATTR_EDITOR_LAYER_PATH).is_some(); - let layer_path: List = current_graphic_row.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH); - let current_transform: DAffine2 = current_graphic_row.attribute_cloned_or_default(ATTR_TRANSFORM); - let current_opacity: f64 = current_graphic_row.attribute_cloned_or(ATTR_OPACITY, 1.); - let current_fill: f64 = current_graphic_row.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let layer_path: List = current_graphic_item.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH); + let current_transform: DAffine2 = current_graphic_item.attribute_cloned_or_default(ATTR_TRANSFORM); + let current_opacity: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY, 1.); + let current_fill: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); - match current_graphic_row.into_element() { + match current_graphic_item.into_element() { // Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries. // A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op. Graphic::Graphic(mut sub_list) => { @@ -150,16 +150,16 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) // Each `|| item.attribute(...)` keeps an attribute the item itself carries // (recomposed with the parent's identity value) even when the parent lacks it if parent_has_transform || item.attribute::(ATTR_TRANSFORM).is_some() { - let row_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); - item.set_attribute(ATTR_TRANSFORM, current_transform * row_transform); + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + item.set_attribute(ATTR_TRANSFORM, current_transform * item_transform); } if parent_has_opacity || item.attribute::(ATTR_OPACITY).is_some() { - let row_opacity: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); - item.set_attribute(ATTR_OPACITY, current_opacity * row_opacity); + let item_opacity: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + item.set_attribute(ATTR_OPACITY, current_opacity * item_opacity); } if parent_has_fill || item.attribute::(ATTR_OPACITY_FILL).is_some() { - let row_fill: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); - item.set_attribute(ATTR_OPACITY_FILL, current_fill * row_fill); + let item_fill: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + item.set_attribute(ATTR_OPACITY_FILL, current_fill * item_fill); } if parent_has_layer_path { item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone()); @@ -178,6 +178,123 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) output } +/// Converts a `Fill` enum into the `List` representation used as paint storage. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. +pub fn fill_to_graphic_list(fill: &Fill) -> Option> { + match fill { + Fill::None => None, + Fill::Solid(color) => Some(List::new_from_element((*color).into())), + Fill::Gradient(gradient) => { + let gradient_item = Item::new_from_element(gradient.stops.clone()) + .with_attribute(ATTR_TRANSFORM, gradient.to_transform()) + .with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type) + .with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method); + let gradient_list = List::new_from_item(gradient_item); + + Some(List::new_from_element(Graphic::Gradient(gradient_list))) + } + } +} + +/// Converts a `Color` into the `List` representation used as paint storage. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn color_to_graphic_list(color: Option) -> Option> { + color.as_ref().map(|color| List::new_from_element((*color).into())) +} + +/// Look up the paint graphics stored under attribute for a vector item, normalizing any graphic list type to `List`. +pub fn graphic_list_at<'a>(list: &'a List, index: usize, attribute: &str) -> Option>> { + list.attribute::>(attribute, index) + .map(Cow::Borrowed) + .or_else(|| list.attribute::>(attribute, index).map(|c| Cow::Owned(c.clone().into_graphic_list()))) + .or_else(|| list.attribute::>(attribute, index).map(|g| Cow::Owned(g.clone().into_graphic_list()))) + .or_else(|| list.attribute::>(attribute, index).map(|v| Cow::Owned(v.clone().into_graphic_list()))) + .or_else(|| list.attribute::>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list()))) + .or_else(|| list.attribute::>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list()))) + // Treat a blank attribute as absent so consumers fall back to the legacy `style` instead of masking it. + .filter(|graphic_list| graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())) +} + +/// Look up the fill paint graphics for a vector item, falling back to the legacy +/// `style.fill` when the attribute is absent or empty. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. +pub fn fill_graphic_list_at(list: &List, index: usize) -> Option>> { + graphic_list_at(list, index, ATTR_FILL).or_else(|| { + let vector = list.element(index)?; + fill_to_graphic_list(vector.style.fill()).map(Cow::Owned) + }) +} + +/// Look up the stroke paint graphics for a vector item, falling back to the legacy +/// `style.stroke.color` when the attribute is absent or empty. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn stroke_graphic_list_at(list: &List, index: usize) -> Option>> { + graphic_list_at(list, index, ATTR_STROKE).or_else(|| { + let vector = list.element(index)?; + color_to_graphic_list(vector.style.stroke().and_then(|s| s.color())).map(Cow::Owned) + }) +} + +/// Check whether the fill paint for a vector item is fully opaque, falling back to +/// the legacy `style.fill` when the attribute is absent. +/// This avoids the `List` allocation that the legacy `Fill` fallback path performs. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. +pub fn is_fill_opaque_at(list: &List, index: usize) -> bool { + if let Some(graphic_list) = graphic_list_at(list, index, ATTR_FILL) { + return graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque()); + } + let Some(vector) = list.element(index) else { return false }; + match vector.style.fill() { + Fill::None => false, + Fill::Solid(color) => color.is_opaque(), + Fill::Gradient(gradient) => gradient.stops.iter().all(|stop| stop.color.is_opaque()), + } +} + +/// Check whether the fill paint for a vector item is fully transparent, falling back to +/// the legacy `style.fill` when the attribute is absent. +/// This avoids the `List` allocation that the legacy `Fill` fallback path performs. +/// TODO: Remove once all fill paint sources flow through `List` directly without going through the `Fill` enum. +pub fn is_fill_fully_transparent_at(list: &List, index: usize) -> bool { + if let Some(graphic_list) = graphic_list_at(list, index, ATTR_FILL) { + return graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()); + } + let Some(vector) = list.element(index) else { return false }; + match vector.style.fill() { + Fill::None => true, + Fill::Solid(color) => color.a() == 0., + Fill::Gradient(gradient) => gradient.stops.iter().all(|stop| stop.color.a() == 0.), + } +} + +/// Check whether the stroke paint for a vector item is fully opaque, falling back to +/// the legacy `style.stroke.color` when the attribute is absent. +/// This avoids the `List` allocation that the legacy `Stroke.color` fallback path performs. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn is_stroke_opaque_at(list: &List, index: usize) -> bool { + if let Some(graphic_list) = graphic_list_at(list, index, ATTR_STROKE) { + return graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque()); + } + let Some(color) = list.element(index).and_then(|vector| vector.style.stroke()).and_then(|stroke| stroke.color()) else { + return false; + }; + color.is_opaque() +} + +/// Check whether the stroke paint for a vector item is fully transparent, falling back to +/// the legacy `style.stroke.color` when the attribute is absent. +/// This avoids the `List` allocation that the legacy `Stroke.color` fallback path performs. +/// TODO: Remove once all stroke paint sources flow through `List` directly without going through `Stroke.color`. +pub fn is_stroke_fully_transparent_at(list: &List, index: usize) -> bool { + if let Some(graphic_list) = graphic_list_at(list, index, ATTR_STROKE) { + return graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()); + } + let Some(color) = list.element(index).and_then(|vector| vector.style.stroke()).and_then(|stroke| stroke.color()) else { + return true; + }; + color.a() == 0. +} + /// Maps from a concrete element type to its corresponding `Graphic` enum variant, /// enabling type-directed casting of typed `List`s from a `Graphic` value. pub trait TryFromGraphic: Clone + Sized { @@ -229,7 +346,7 @@ impl IntoGraphicList for List { impl IntoGraphicList for List { fn into_graphic_list(self) -> List { - // Propagate `editor:layer_path` from item 0 onto the wrapper Graphic row so a subsequent + // Propagate `editor:layer_path` from item 0 onto the wrapper Graphic item so a subsequent // `flatten_graphic_list` doesn't overwrite the inner Vector's stamp with an empty value let layer_path: List = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0); let mut graphic_list = List::new_from_element(Graphic::Vector(self)); @@ -341,11 +458,82 @@ impl Graphic { Graphic::Vector(vector) => (0..vector.len()).all(|index| { let Some(element) = vector.element(index) else { return false }; let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.); - opacity > 1. - f64::EPSILON && element.style.fill().is_opaque() && element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) + + let fill_opaque_or_absent = match graphic_list_at(vector, index, ATTR_FILL) { + Some(graphic_list) => graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()), + None => element.style.fill().is_opaque(), + }; + + let stroke_invisible_or_transparent = element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) + || if let Some(graphic_list) = graphic_list_at(vector, index, ATTR_STROKE) { + graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()) + } else { + element.style.stroke().and_then(|stroke| stroke.color()).is_none_or(|color| color.a() == 0.) + }; + + opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent }), _ => false, } } + + pub fn is_opaque(&self) -> bool { + match self { + Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque), + Graphic::Vector(list) => { + !list.is_empty() + && (0..list.len()).all(|i| { + let Some(vector) = list.element(i) else { return false }; + let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.); + let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.); + let fill_opaque = opacity_fill >= 1. - f64::EPSILON && is_fill_opaque_at(list, i); + let stroke_opaque_or_invisible = vector.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_stroke_opaque_at(list, i); + opacity >= 1. - f64::EPSILON && fill_opaque && stroke_opaque_or_invisible + }) + } + Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), + Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, + } + } + + pub fn is_fully_transparent(&self) -> bool { + match self { + Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent), + Graphic::Vector(list) => (0..list.len()).all(|i| { + let Some(vector) = list.element(i) else { return false }; + let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.); + if opacity <= f64::EPSILON { + return true; + } + let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.); + let fill_invisible = opacity_fill <= f64::EPSILON || is_fill_fully_transparent_at(list, i); + let stroke_invisible = vector.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_stroke_fully_transparent_at(list, i); + fill_invisible && stroke_invisible + }), + Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), + Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), + Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false, + } + } + + /// True if this paint opaquely covers the entire fill region. + /// Vector, Raster, and a nested Graphic may leave gaps, so they return false. + pub fn covers_opaquely(&self) -> bool { + matches!(self, Graphic::Color(_) | Graphic::Gradient(_)) && self.is_opaque() + } + + /// Returns true if this graphic's inner list is empty. + pub fn is_empty(&self) -> bool { + match self { + Graphic::Graphic(list) => list.is_empty(), + Graphic::Vector(list) => list.is_empty(), + Graphic::Color(list) => list.is_empty(), + Graphic::Gradient(list) => list.is_empty(), + Graphic::RasterCPU(list) => list.is_empty(), + Graphic::RasterGPU(list) => list.is_empty(), + } + } } impl BoundingBox for Graphic { @@ -373,17 +561,17 @@ impl BoundingBox for Graphic { } impl ListConvert for Vector { - fn convert_row(self) -> Graphic { + fn convert_item(self) -> Graphic { Graphic::Vector(List::new_from_element(self)) } } impl ListConvert for Raster { - fn convert_row(self) -> Graphic { + fn convert_item(self) -> Graphic { Graphic::RasterCPU(List::new_from_element(self)) } } impl ListConvert for Raster { - fn convert_row(self) -> Graphic { + fn convert_item(self) -> Graphic { Graphic::RasterGPU(List::new_from_element(self)) } } @@ -423,9 +611,9 @@ impl AtIndex for List { type Output = List; fn at_index(&self, index: usize) -> Option { - self.clone_item(index).map(|row| { + self.clone_item(index).map(|item| { let mut result_list = Self::default(); - result_list.push(row); + result_list.push(item); result_list }) } @@ -456,9 +644,9 @@ impl OmitIndex for List { let mut result = Self::default(); for i in 0..self.len() { if i != index - && let Some(row) = self.clone_item(i) + && let Some(item) = self.clone_item(i) { - result.push(row); + result.push(item); } } result @@ -505,3 +693,79 @@ mod tests { assert_eq!(flattened.attribute_cloned_or_default::(ATTR_OPACITY, 0), 0.5); } } + +#[cfg(test)] +mod graphic_is_opaque_tests { + use vector_types::{GradientSpreadMethod, GradientStop}; + + use super::*; + + fn color_graphic(alpha: f64) -> Graphic { + let color = Color::from_rgbaf32(1.0, 0.0, 0.0, alpha as f32).unwrap(); + Graphic::Color(List::new_from_element(color)) + } + + fn gradient_graphic(gradient: GradientStops) -> Graphic { + let mut gradient_list = List::new_from_element(gradient); + gradient_list.set_attribute(ATTR_SPREAD_METHOD, 0, GradientSpreadMethod::Pad); + Graphic::Gradient(gradient_list) + } + + #[test] + fn opaque_color_is_opaque() { + let g = color_graphic(1.0); + assert!(g.is_opaque()); + } + + #[test] + fn transparent_color_is_not_opaque() { + let g = color_graphic(0.5); + assert!(!g.is_opaque()); + } + + #[test] + fn vector_is_not_opaque() { + let g = Graphic::Vector(List::default()); + assert!(!g.is_opaque()); + } + + #[test] + fn gradient_with_all_opaque_stops_is_opaque() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(g.is_opaque()); + } + + #[test] + fn gradient_with_transparent_stop_is_not_opaque() { + let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 0.5).unwrap(); + let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap(); + let gradient = GradientStops::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: color_1, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: color_2, + }, + ]); + let g = gradient_graphic(gradient); + assert!(!g.is_opaque()); + } +} diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 883640d05d..197887add0 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,24 +1,104 @@ use crate::renderer::{RenderParams, format_transform_matrix}; +use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; +use core_types::list::List; use core_types::uuid::generate_uuid; -use glam::DAffine2; -use graphic_types::vector_types::gradient::{Gradient, GradientType}; -use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; +use glam::{DAffine2, DVec2}; +use graphic_types::Graphic; +use graphic_types::vector_types::gradient::GradientType; +use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; +use vector_types::GradientStops; use vector_types::gradient::GradientSpreadMethod; +#[derive(Copy, Clone, PartialEq)] +pub enum PaintTarget { + Fill, + Stroke, +} + +impl PaintTarget { + fn paint_attr(self) -> &'static str { + match self { + Self::Fill => "fill", + Self::Stroke => "stroke", + } + } + + fn opacity_attr(self) -> &'static str { + match self { + Self::Fill => "fill-opacity", + Self::Stroke => "stroke-opacity", + } + } +} + pub trait RenderExt { type Output; - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output; + + #[allow(clippy::too_many_arguments)] + fn render( + &self, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + render_params: &RenderParams, + target: PaintTarget, + ) -> Self::Output; +} + +impl RenderExt for List { + type Output = String; + + fn render( + &self, + _svg_defs: &mut String, + _item_transform: DAffine2, + _element_transform: DAffine2, + _stroke_transform: DAffine2, + _bounds: DAffine2, + _transformed_bounds: DAffine2, + _render_params: &RenderParams, + target: PaintTarget, + ) -> Self::Output { + let Some(color) = self.element(0) else { return r#" fill="none""#.to_string() }; + + let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex()); + if color.a() < 1. { + let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.); + } + + result + } } -impl RenderExt for Gradient { +impl RenderExt for List { type Output = u64; /// Adds the gradient def through mutating the first argument, returning the gradient ID. - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, _render_params: &RenderParams) -> Self::Output { + fn render( + &self, + svg_defs: &mut String, + _item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + _render_params: &RenderParams, + _target: PaintTarget, + ) -> Self::Output { let mut stop = String::new(); - for (position, color, original_midpoint) in self.stops.interpolated_samples() { + + let Some(stops) = self.element(0) else { return 0 }; + let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + + for (position, color, original_midpoint) in stops.interpolated_samples() { stop.push_str("") } - let transform_points = element_transform * stroke_transform * bounds; - let start = transform_points.transform_point2(self.start); - let end = transform_points.transform_point2(self.end); + let transform_points = element_transform * stroke_transform * bounds * gradient_transform; + let start = transform_points.transform_point2(DVec2::ZERO); + let end = transform_points.transform_point2(DVec2::X); let gradient_transform = if transformed_bounds.matrix2.determinant() != 0. { transformed_bounds.inverse() @@ -49,15 +129,15 @@ impl RenderExt for Gradient { format!(r#" gradientTransform="{gradient_transform}""#) }; - let spread_method = if self.spread_method == GradientSpreadMethod::Pad { + let spread_method = if spread_method == GradientSpreadMethod::Pad { String::new() } else { - format!(r#" spreadMethod="{}""#, self.spread_method.svg_name()) + format!(r#" spreadMethod="{}""#, spread_method.svg_name()) }; let gradient_id = generate_uuid(); - match self.gradient_type { + match gradient_type { GradientType::Linear => { let _ = write!( svg_defs, @@ -79,43 +159,22 @@ impl RenderExt for Gradient { } } -impl RenderExt for Fill { - type Output = String; - - /// Renders the fill, adding necessary defs through mutating the first argument. - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output { - match self { - Self::None => r#" fill="none""#.to_string(), - Self::Solid(color) => { - let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex()); - if color.a() < 1. { - let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } - result - } - Self::Gradient(gradient) => { - let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); - format!(r##" fill="url('#{gradient_id}')""##) - } - } - } -} - impl RenderExt for Stroke { type Output = String; - /// Provide the SVG attributes for the stroke. + /// Provide the shape-related SVG attributes for the stroke. The paint-related attributes for the stroke are generated from `List.render` with `PaintTarget::Stroke`. fn render( &self, _svg_defs: &mut String, + _item_transform: DAffine2, _element_transform: DAffine2, _stroke_transform: DAffine2, _bounds: DAffine2, _transformed_bounds: DAffine2, render_params: &RenderParams, + _target: PaintTarget, ) -> Self::Output { // Don't render a stroke at all if it would be invisible - let Some(color) = self.color else { return String::new() }; if !self.has_renderable_stroke() { return String::new(); } @@ -133,10 +192,7 @@ impl RenderExt for Stroke { let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow); // Render the needed stroke attributes - let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex()); - if color.a() < 1. { - let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); - } + let mut attributes = String::new(); if let Some(mut weight) = weight { if stroke_align.is_some() && render_params.aligned_strokes { weight *= 2.; @@ -165,18 +221,84 @@ impl RenderExt for Stroke { } } -impl RenderExt for PathStyle { +impl RenderExt for List { type Output = String; - /// Renders the shape's fill and stroke attributes as a string with them concatenated together. - #[allow(clippy::too_many_arguments)] - fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> String { - let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params); - let stroke_attribute = self - .stroke - .as_ref() - .map(|stroke| stroke.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params)) - .unwrap_or_default(); - format!("{fill_attribute}{stroke_attribute}") + fn render( + &self, + svg_defs: &mut String, + item_transform: DAffine2, + element_transform: DAffine2, + stroke_transform: DAffine2, + bounds: DAffine2, + transformed_bounds: DAffine2, + render_params: &RenderParams, + target: PaintTarget, + ) -> Self::Output { + let fill_graphic = self.element(0); + let paint_attr = target.paint_attr(); + + match fill_graphic { + Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target), + Some(Graphic::Gradient(gradient_list)) => { + let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target); + format!(r##" {paint_attr}="url(#{gradient_id})""##) + } + Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => { + let bounds = if target == PaintTarget::Stroke { + // To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly. + let inverse = |len: f64| if len > 0. { 1. / len } else { 0. }; + let inflate = DVec2::new(inverse(item_transform.matrix2.x_axis.length()), inverse(item_transform.matrix2.y_axis.length())); + let min = bounds.transform_point2(DVec2::ZERO) - inflate; + let max = bounds.transform_point2(DVec2::ONE) + inflate; + DAffine2::from_scale_angle_translation(max - min, 0., min) + } else { + bounds + }; + render_svg_pattern(svg_defs, self, stroke_transform, bounds, render_params) + .map(|id| format!(r##" {paint_attr}="url(#{id})""##)) + .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)) + } + None => format!(r#" {paint_attr}="none""#), + } + } +} + +/// Emits an SVG `` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID. +/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet. +fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option { + let min = bounds.transform_point2(DVec2::ZERO); + let max = bounds.transform_point2(DVec2::ONE); + let size = max - min; + if size.x <= 0. || size.y <= 0. { + return None; } + + // Render the pattern content recursively + let mut content = SvgRender::new(); + fill_graphic_list.render_svg(&mut content, &render_params.for_pattern()); + + // Unwrap the inner def element + write!(svg_defs, "{}", content.svg_defs).unwrap(); + + let pattern_transform = stroke_transform * DAffine2::from_translation(min); + let transform_str = format_transform_matrix(pattern_transform); + let transform_attr = if transform_str.is_empty() { + String::new() + } else { + format!(r#" patternTransform="{transform_str}""#) + }; + + let pattern_id = format!("pattern-{}", generate_uuid()); + write!( + svg_defs, + r##""##, + size.x, size.y, + ) + .unwrap(); + + let content_shift = format_transform_matrix(DAffine2::from_translation(-min)); + write!(svg_defs, r##"{}"##, content.svg.to_svg_string()).unwrap(); + + Some(pattern_id) } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 7cae299753..c04d787592 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -1,4 +1,4 @@ -use crate::render_ext::RenderExt; +use crate::render_ext::{PaintTarget, RenderExt}; use crate::to_peniko::{BlendModeExt, ToPenikoColor}; use core_types::CacheHash; use core_types::blending::BlendMode; @@ -6,7 +6,7 @@ use core_types::bounds::BoundingBox; use core_types::bounds::RenderBoundingBox; use core_types::color::Color; use core_types::color::SRGBA8; -use core_types::list::{Item, List}; +use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List}; use core_types::math::quad::Quad; use core_types::render_complexity::RenderComplexity; use core_types::transform::Footprint; @@ -18,13 +18,14 @@ use core_types::{ use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use graphene_hash::CacheHashWrapper; +use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, is_stroke_fully_transparent_at, stroke_graphic_list_at}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; -use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign}; +use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::{Artboard, Graphic, Vector}; -use kurbo::{Affine, Cap, Join, Shape}; +use kurbo::{Affine, Cap, Join, Shape, StrokeOpts}; use num_traits::Zero; use std::collections::{HashMap, HashSet}; use std::fmt::Write; @@ -218,6 +219,8 @@ pub struct RenderParams { pub alignment_parent_transform: Option, pub aligned_strokes: bool, pub override_paint_order: bool, + /// Are we rendering for a pattern content + pub inside_pattern: bool, pub artboard_background: Option, /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. pub viewport_zoom: f64, @@ -233,8 +236,12 @@ impl RenderParams { Self { alignment_parent_transform, ..*self } } + pub fn for_pattern(&self) -> Self { + Self { inside_pattern: true, ..*self } + } + pub fn to_canvas(&self) -> bool { - !self.for_export && !self.thumbnail && !self.for_mask + !self.for_export && !self.thumbnail && !self.for_mask && !self.inside_pattern } } @@ -329,6 +336,103 @@ fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_p scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path); } +/// Emits an SVG `` element with the resolved fill attribute corresponding to the given fill_graphic. +#[allow(clippy::too_many_arguments)] +fn emit_svg_fill_path( + render: &mut SvgRender, + d: String, + fill_graphic_list: Option<&List>, + item_transform: DAffine2, + element_transform: DAffine2, + applied_stroke_transform: DAffine2, + bounds_matrix: DAffine2, + transformed_bounds_matrix: DAffine2, + render_params: &RenderParams, +) { + render.leaf_tag("path", |attributes| { + attributes.push("d", d); + let matrix = format_transform_matrix(element_transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + let defs = &mut attributes.0.svg_defs; + let fill_attribute = fill_graphic_list + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + PaintTarget::Fill, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()); + attributes.push_val(fill_attribute); + }); +} + +fn create_peniko_gradient_brush(gradient_list: &List, parent_vector: &Vector, parent_transform: &DAffine2, multiplied_transform: &DAffine2) -> Option { + let stops = gradient_list.element(0)?; + + let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); + let spread_method: GradientSpreadMethod = gradient_list.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); + + let mut peniko_stops = peniko::ColorStops::new(); + for (position, color, _) in stops.interpolated_samples() { + peniko_stops.push(peniko::ColorStop { + offset: position as f32, + color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), + }); + } + + let bounds = parent_vector.nonzero_bounding_box(); + let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); + + let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { + parent_transform.inverse() + } else { + Default::default() + }; + let mod_points = inverse_parent_transform * multiplied_transform * bound_transform * gradient_transform; + + let start = mod_points.transform_point2(DVec2::ZERO); + let end = mod_points.transform_point2(DVec2::X); + + let brush = peniko::Brush::Gradient(peniko::Gradient { + kind: match gradient_type { + GradientType::Linear => peniko::LinearGradientPosition { + start: to_point(start), + end: to_point(end), + } + .into(), + GradientType::Radial => { + let radius = start.distance(end); + peniko::RadialGradientPosition { + start_center: to_point(start), + start_radius: 0., + end_center: to_point(start), + end_radius: radius as f32, + } + .into() + } + }, + extend: match spread_method { + GradientSpreadMethod::Pad => peniko::Extend::Pad, + GradientSpreadMethod::Reflect => peniko::Extend::Reflect, + GradientSpreadMethod::Repeat => peniko::Extend::Repeat, + }, + stops: peniko_stops, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, + ..Default::default() + }); + + Some(brush) +} + // TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes. // This will require that the transform for child layers into that layer space be calculated, or it could be returned from the RenderOutput instead of click targets. #[derive(Debug, Default, Clone, PartialEq, DynAny)] @@ -346,6 +450,14 @@ pub struct RenderMetadata { pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_data: HashMap>, + /// Per-layer `ATTR_FILL` row attribute, exposed so message handlers can read paint + /// information that lives on the list rather than on `PathStyle.fill`. + #[cfg_attr(feature = "serde", serde(skip))] + pub fill_attributes: HashMap>>, + /// Per-layer `ATTR_STROKE` row attribute, exposed so message handlers can read + /// stroke paint information that lives on the list rather than on `Stroke.color`. + #[cfg_attr(feature = "serde", serde(skip))] + pub stroke_attributes: HashMap>>, pub backgrounds: Vec, } @@ -369,6 +481,8 @@ impl RenderMetadata { text_frames, clip_targets, vector_data, + fill_attributes, + stroke_attributes, backgrounds, } = self; upstream_footprints.extend(other.upstream_footprints.iter()); @@ -379,6 +493,8 @@ impl RenderMetadata { text_frames.extend(other.text_frames.iter()); clip_targets.extend(other.clip_targets.iter()); vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); + fill_attributes.extend(other.fill_attributes.iter().map(|(id, data)| (*id, data.clone()))); + stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone()))); // TODO: Find a better non O(n^2) way to merge backgrounds for background in &other.backgrounds { @@ -921,7 +1037,7 @@ impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { let Some(vector) = self.element(index) else { continue }; - let multiplied_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); @@ -929,15 +1045,17 @@ impl Render for List { // 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(multiplied_transform); + let applied_stroke_transform = set_stroke_transform.unwrap_or(item_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| item_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(); + let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds); let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]); let transformed_bounds_matrix = element_transform * DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]); + let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]); let mut path = String::new(); @@ -952,32 +1070,35 @@ impl Render for List { MaskType::Mask }; + let fill_graphic_list = fill_graphic_list_at(self, index); + let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); + + let stroke_graphic_list = stroke_graphic_list_at(self, index); + let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0)); + let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); - let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()); - let can_use_paint_order = !(vector.style.fill().is_none() || !vector.style.fill().is_opaque() || mask_type == MaskType::Clip); + let can_draw_aligned_stroke = path_is_closed + && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) + && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); + let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); + let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; + let use_face_fill = vector.use_face_fill(); if needs_separate_alignment_fill && !wants_stroke_below { - render.leaf_tag("path", |attributes| { - attributes.push("d", path.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); + emit_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } let push_id = needs_separate_alignment_fill.then_some({ @@ -989,35 +1110,27 @@ impl Render for List { // The mask must draw at full alpha so the SVG ``/`` fully zeroes the path interior. // The wrapping SVG group (above) handles the user-set opacity. - let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, multiplied_transform)); + let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform)); (id, mask_type, vector_item) }); - let use_face_fill = vector.use_face_fill(); if use_face_fill { for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - let face_d = face_path.to_svg(); - render.leaf_tag("path", |attributes| { - attributes.push("d", face_d.clone()); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_only = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_only); - }); + + emit_svg_fill_path( + render, + face_d, + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } } @@ -1057,20 +1170,84 @@ impl Render for List { let mut render_params = render_params.clone(); render_params.aligned_strokes = can_draw_aligned_stroke; - render_params.override_paint_order = can_draw_aligned_stroke && can_use_paint_order; - - let mut style = vector.style.clone(); - if needs_separate_alignment_fill || use_face_fill { - style.clear_fill(); - } + render_params.override_paint_order = override_paint_order; + + let stroke_shape_attribute = vector + .style + .stroke() + .map(|stroke| { + if stroke_graphic_list.as_ref().and_then(|l| l.element(0)).is_some() { + stroke.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + PaintTarget::Stroke, + ) + } else { + String::new() + } + }) + .unwrap_or_default(); + + // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback + let stroke_visible = vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); + let stroke_attribute = if stroke_visible { + stroke_graphic_list + .as_deref() + .map(|list| { + // Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke. + // Only clipping-based paints need the stroke-inclusive bbox. + let paint_bounds = match list.element(0) { + Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix, + _ => stroke_bounds_matrix, + }; + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + paint_bounds, + transformed_bounds_matrix, + &render_params, + PaintTarget::Stroke, + ) + }) + .unwrap_or_else(|| r#" stroke="none""#.to_string()) + } else { + String::new() + }; - let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params); + let fill_attribute = if needs_separate_alignment_fill || use_face_fill { + r#" fill="none""#.to_string() + } else { + fill_graphic_list + .as_deref() + .map(|list| { + list.render( + defs, + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + &render_params, + PaintTarget::Fill, + ) + }) + .unwrap_or_else(|| r#" fill="none""#.to_string()) + }; if let Some((id, mask_type, _)) = push_id { let selector = format!("url(#{id})"); attributes.push(mask_type.to_attribute(), selector); } - attributes.push_val(fill_and_stroke); + attributes.push_val(fill_attribute); + attributes.push_val(stroke_shape_attribute); + attributes.push_val(stroke_attribute); if vector.is_branching() && !use_face_fill { attributes.push("fill-rule", "evenodd"); @@ -1088,31 +1265,22 @@ impl Render for List { // When splitting passes and stroke is below, draw the fill after the stroke. if needs_separate_alignment_fill && wants_stroke_below { - render.leaf_tag("path", |attributes| { - attributes.push("d", path); - let matrix = format_transform_matrix(element_transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - let mut style = vector.style.clone(); - style.clear_stroke(); - let fill_and_stroke = style.render( - &mut attributes.0.svg_defs, - element_transform, - applied_stroke_transform, - bounds_matrix, - transformed_bounds_matrix, - render_params, - ); - attributes.push_val(fill_and_stroke); - }); + emit_svg_fill_path( + render, + path.clone(), + fill_graphic_list.as_deref(), + item_transform, + element_transform, + applied_stroke_transform, + bounds_matrix, + transformed_bounds_matrix, + render_params, + ); } } } - fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - use graphic_types::vector_types::vector::style::{GradientType, StrokeCap, StrokeJoin}; - + fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { for index in 0..self.len() { use graphic_types::vector_types::vector; @@ -1146,6 +1314,9 @@ impl Render for List { } } + let fill_graphic_list = fill_graphic_list_at(self, index); + let stroke_graphic_list = stroke_graphic_list_at(self, index); + // If we're using opacity or a blend mode, we need to push a layer let blend_mode = match render_params.render_mode { RenderMode::Outline => peniko::Mix::Normal, @@ -1157,7 +1328,9 @@ impl Render for List { // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. let stroke = element.style.stroke(); - let can_draw_aligned_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); + let can_draw_aligned_stroke = !is_stroke_fully_transparent_at(self, index) + && stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) + && element.stroke_bezier_paths().all(|p| p.closed()); let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; if opacity < 1. || blend_mode_attr != BlendMode::default() { @@ -1181,75 +1354,43 @@ impl Render for List { let use_layer = can_draw_aligned_stroke; let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow); - // Closures to avoid duplicated fill/stroke drawing logic - let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() { - Fill::Solid(color) => { - let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); - } - Fill::Gradient(gradient) => { - let mut stops = peniko::ColorStops::new(); - for (position, color, _) in gradient.stops.interpolated_samples() { - stops.push(peniko::ColorStop { - offset: position as f32, - color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()), - }); - } - - let bounds = element.nonzero_bounding_box(); - let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); - - let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. { - parent_transform.inverse() - } else { - Default::default() - }; - let mod_points = inverse_parent_transform * multiplied_transform * bound_transform; + let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { + let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; - let start = mod_points.transform_point2(gradient.start); - let end = mod_points.transform_point2(gradient.end); + for paint_index in 0..fill_graphic.len() { + let Some(paint) = fill_graphic.element(paint_index) else { continue }; + match paint { + Graphic::Color(list) => { + let Some(color) = list.element(0) else { continue }; - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind: match gradient.gradient_type { - GradientType::Linear => peniko::LinearGradientPosition { - start: to_point(start), - end: to_point(end), - } - .into(), - GradientType::Radial => { - let radius = start.distance(end); - peniko::RadialGradientPosition { - start_center: to_point(start), - start_radius: 0., - end_center: to_point(start), - end_radius: radius as f32, - } - .into() - } - }, - extend: match gradient.spread_method { - GradientSpreadMethod::Pad => peniko::Extend::Pad, - GradientSpreadMethod::Reflect => peniko::Extend::Reflect, - GradientSpreadMethod::Repeat => peniko::Extend::Repeat, - }, - stops, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied, - ..Default::default() - }); - let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { - element_transform.inverse() - } else { - Default::default() + let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path); + } + Graphic::Gradient(list) => { + let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else { + continue; + }; + + let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); + } + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => { + scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); + paint.render_to_vello(scene, multiplied_transform, context, render_params); + scene.pop_layer(); + } }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path); } - Fill::None => {} }; // Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering. let use_face_fill = element.use_face_fill(); - let do_fill = |scene: &mut Scene| { + let do_fill = |scene: &mut Scene, context: &mut RenderContext| { if use_face_fill { for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) { face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); @@ -1257,21 +1398,24 @@ impl Render for List { for element in face_path { kurbo_path.push(element); } - do_fill_path(scene, &kurbo_path, peniko::Fill::NonZero); + do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); } } else if element.is_branching() { - do_fill_path(scene, &path, peniko::Fill::EvenOdd); + do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); } else { - do_fill_path(scene, &path, peniko::Fill::NonZero); + do_fill_path(scene, context, &path, peniko::Fill::NonZero); } }; - let do_stroke = |scene: &mut Scene, width_scale: f64| { - if let Some(stroke) = element.style.stroke() { - let color = match stroke.color { - Some(color) => SRGBA8::from(color).to_peniko_color(), - None => peniko::Color::TRANSPARENT, + let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { + let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return }; + let Some(stroke) = element.style.stroke() else { return }; + + for paint_index in 0..stroke_graphic_list.len() { + let Some(stroke_graphic) = stroke_graphic_list.element(paint_index) else { + continue; }; + let cap = match stroke.cap { StrokeCap::Butt => Cap::Butt, StrokeCap::Round => Cap::Round, @@ -1293,9 +1437,38 @@ impl Render for List { dash_offset: stroke.dash_offset, }; - if stroke.width > 0. { - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path); - } + if stroke.width <= 0. { + continue; + }; + + match stroke_graphic { + Graphic::Color(list) => { + let Some(color) = list.element(0) else { continue }; + let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color()); + + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path); + } + Graphic::Gradient(list) => { + let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else { + continue; + }; + let inverse_element_transform = if element_transform.matrix2.determinant() != 0. { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array()); + + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); + } + Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => { + let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); + + scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); + stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params); + scene.pop_layer(); + } + }; } }; @@ -1332,24 +1505,24 @@ impl Render for List { if wants_stroke_below { scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - do_stroke(scene, 2.); + do_stroke(scene, 2., context); scene.pop_layer(); scene.pop_layer(); - do_fill(scene); + do_fill(scene, context); } else { // Fill first (unclipped), then stroke (clipped) above - do_fill(scene); + do_fill(scene, context); scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform)); + vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform)); scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - do_stroke(scene, 2.); + do_stroke(scene, 2., context); scene.pop_layer(); scene.pop_layer(); @@ -1368,8 +1541,8 @@ impl Render for List { for operation in &order { match operation { - Op::Fill => do_fill(scene), - Op::Stroke => do_stroke(scene, 1.), + Op::Fill => do_fill(scene, context), + Op::Stroke => do_stroke(scene, 1., context), } } } @@ -1421,17 +1594,28 @@ impl Render for List { let item_relative_transform = item_zero_inverse * transform; let mut click_targets_unwrapped = Vec::new(); - extend_targets_from_vector(&mut click_targets_unwrapped, click_target_vector, item_relative_transform); + extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform); accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); // Outlines always use source geometry so the visual outline reflects actual letterforms let mut outlines_unwrapped = Vec::new(); - extend_targets_from_vector(&mut outlines_unwrapped, source, item_relative_transform); + extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform); accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); // Source geometry (not the click-target override) so editing tools work on letterforms. + // Recorded together with `vector_data` from the same (first) row so `style` stays consistent with the paint. // Only item 0 is recorded since editing tools can only target a single item currently. - metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(source.clone())); + // If that row has no paint attribute, none is recorded and consumers fall back to `style`. + if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { + e.insert(Arc::new(source.clone())); + + if let Some(fill_graphic) = graphic_list_at(self, index, ATTR_FILL) { + metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned())); + } + if let Some(stroke_graphic) = graphic_list_at(self, index, ATTR_STROKE) { + metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned())); + } + } // Surface `editor:text_frame` for the Text tool's drag cage if let Some(&frame) = self.attribute::(ATTR_EDITOR_TEXT_FRAME, index) { @@ -1467,7 +1651,7 @@ impl Render for List { // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) let vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); - extend_targets_from_vector(click_targets, vector, transform); + extend_targets_from_vector(click_targets, self, index, vector, transform); } } @@ -1477,7 +1661,7 @@ impl Render for List { let Some(source) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - extend_targets_from_vector(outlines, source, transform); + extend_targets_from_vector(outlines, self, index, source, transform); } } @@ -1490,15 +1674,21 @@ impl Render for List { /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. -fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, transform: DAffine2) { - let filled = vector.style.fill() != &Fill::None; +fn extend_targets_from_vector(targets: &mut Vec, vector_list: &List, index: usize, geometry: &Vector, transform: DAffine2) { + let filled = if let Some(graphic_list) = graphic_list_at(vector_list, index, ATTR_FILL) { + graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty()) + } else if let Some(vector) = vector_list.element(index) { + !matches!(vector.style.fill(), Fill::None) + } else { + false + }; - let mut subpaths: Vec> = vector.stroke_bezier_paths().collect(); + let mut subpaths: Vec> = geometry.stroke_bezier_paths().collect(); let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); // Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side, // so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths. - let stroke_width = vector.style.stroke().map_or(0., |stroke| { + let stroke_width = geometry.style.stroke().map_or(0., |stroke| { if stroke.align.is_not_centered() && all_subpaths_closed { stroke.weight * 2. } else { @@ -1518,7 +1708,7 @@ fn extend_targets_from_vector(targets: &mut Vec, vector: &Vector, t targets.push(click_target); } - for click_target in extend_free_point_targets(vector, transform) { + for click_target in extend_free_point_targets(geometry, transform) { targets.push(click_target); } } diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 9df066e76e..e3d61c12e9 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -587,6 +587,12 @@ impl Gradient { Some(index) } + + /// Builds the affine that places the gradient endpoints at `start` and `end` when applied to canonical gradient space (0, 0) -> (1, 0). + pub fn to_transform(&self) -> DAffine2 { + let direction = self.end - self.start; + DAffine2::from_cols(direction, direction.perp(), self.start) + } } // TODO: Eventually remove this migration document upgrade code @@ -625,3 +631,27 @@ impl core_types::bounds::BoundingBox for GradientStops { core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)]) } } + +#[cfg(test)] +mod tests { + use super::*; + use glam::DVec2; + + fn linear_gradient(start: DVec2, end: DVec2) -> Gradient { + Gradient { start, end, ..Default::default() } + } + + #[test] + fn to_transform_roundtrip() { + let cases = [(DVec2::ZERO, DVec2::X), (DVec2::new(10., 20.), DVec2::new(50., 30.)), (DVec2::new(-5., -5.), DVec2::new(5., 3.))]; + + for (start, end) in cases { + let transform = linear_gradient(start, end).to_transform(); + let recovered_start = transform.transform_point2(DVec2::ZERO); + let recovered_end = transform.transform_point2(DVec2::X); + + assert!((recovered_start - start).length() < 1e-10); + assert!((recovered_end - end).length() < 1e-10); + } + } +} diff --git a/node-graph/libraries/vector-types/src/vector/style.rs b/node-graph/libraries/vector-types/src/vector/style.rs index 0206b7b149..f402727ae1 100644 --- a/node-graph/libraries/vector-types/src/vector/style.rs +++ b/node-graph/libraries/vector-types/src/vector/style.rs @@ -580,7 +580,7 @@ impl Stroke { } pub fn has_renderable_stroke(&self) -> bool { - self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.) + self.weight > 0. } } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index fe502add91..afe6987de6 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU}; use core::hash::{Hash, Hasher}; use core_types::blending::BlendMode; use core_types::bounds::{BoundingBox, RenderBoundingBox}; -use core_types::list::{Item, List, ListDyn}; +use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, ListDyn}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::transform::{Footprint, Transform}; use core_types::uuid::NodeId; @@ -1224,13 +1224,22 @@ async fn solidify_stroke(_: impl Ctx, #[ } // If the original vector has a fill, preserve it as a separate item with the stroke cleared. - let has_fill = !vector.style.fill().is_none(); + let has_attr_fill = attributes.keys().any(|k| k == ATTR_FILL); + let has_fill = has_attr_fill || !vector.style.fill().is_none(); let fill_row = has_fill.then(|| { vector.style.clear_stroke(); - Item::from_parts(vector, attributes.clone()) + let mut fill_attributes = attributes.clone(); + // No stroke remains on the fill row + fill_attributes.remove::>(ATTR_STROKE); + Item::from_parts(vector, fill_attributes) }); - let stroke_row = Item::from_parts(solidified_stroke, attributes); + let mut stroke_attributes = attributes; + // Drop the original fill and use the stroke paint to fill the outlined stroke + stroke_attributes.remove::>(ATTR_FILL); + stroke_attributes.rename(ATTR_STROKE, ATTR_FILL); + + let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes); // Ordering based on the paint order. The first item in the `List` is rendered below the second. match paint_order { From 43ea5e60822179bcf1c70a525afdc4bc022a77bc Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 12 Jun 2026 01:20:59 -0700 Subject: [PATCH 2/5] Remove wasm-pack from dev tooling, replacing it with custom tooling --- .devcontainer/devcontainer.json | 2 +- .github/workflows/build.yml | 37 +++++--- .nix/dev.nix | 1 - .nix/pkgs/graphite.nix | 2 - frontend/.gitignore | 2 +- frontend/README.md | 2 +- frontend/package-installer.js | 2 +- frontend/package.json | 18 +--- frontend/wrapper/Cargo.toml | 16 ---- tools/cargo-run/src/lib.rs | 95 ++++++++++++++++++++- tools/cargo-run/src/main.rs | 43 ++++++++-- tools/cargo-run/src/requirements.rs | 99 ++++++++++++++++++++-- tools/cargo-run/src/wasm.rs | 125 ++++++++++++++++++++++++++++ 13 files changed, 372 insertions(+), 72 deletions(-) create mode 100644 tools/cargo-run/src/wasm.rs diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bf2a7357d9..7161476c23 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ }, "ghcr.io/devcontainers/features/node:1": {} }, - "onCreateCommand": "cargo install cargo-watch wasm-pack cargo-about && cargo install -f wasm-bindgen-cli@0.2.121", + "onCreateCommand": "cargo install cargo-watch cargo-about && cargo install -f wasm-bindgen-cli@0.2.121", "customizations": { "vscode": { // NOTE: Keep this in sync with `.vscode/extensions.json` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cde93fd7bc..69d607305a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,8 @@ jobs: RUSTC_WRAPPER: /usr/bin/sccache CARGO_INCREMENTAL: 0 SCCACHE_DIR: /var/lib/github-actions/.cache + WASM_BINDGEN_CLI_VERSION: "0.2.121" + BINARYEN_VERSION: "129" steps: - name: 📥 Clone repository uses: actions/checkout@v6 @@ -65,19 +67,11 @@ jobs: repository: ${{ inputs.checkout_repo || github.repository }} ref: ${{ inputs.checkout_ref || '' }} - - name: 🗑 Clear wasm-bindgen cache - run: rm -r ~/.cache/.wasm-pack || true - - name: 🟢 Install Node.js uses: actions/setup-node@v6 with: node-version-file: .nvmrc - - name: 🚧 Install build dependencies - run: | - cd frontend - npm run setup - - name: 🦀 Install Rust uses: actions-rust-lang/setup-rust-toolchain@v1 with: @@ -87,6 +81,19 @@ jobs: rustflags: "" target: wasm32-unknown-unknown + - name: 🚧 Install wasm-bindgen-cli and Binaryen wasm-opt + run: | + if ! wasm-bindgen --version 2>/dev/null | grep -qF "$WASM_BINDGEN_CLI_VERSION"; then + cargo install -f "wasm-bindgen-cli@$WASM_BINDGEN_CLI_VERSION" + fi + + BINARYEN_DIR="$HOME/.cache/binaryen-version_$BINARYEN_VERSION" + if [ ! -x "$BINARYEN_DIR/bin/wasm-opt" ]; then + mkdir -p "$BINARYEN_DIR" + curl -sSfL "https://github.com/WebAssembly/binaryen/releases/download/version_$BINARYEN_VERSION/binaryen-version_$BINARYEN_VERSION-x86_64-linux.tar.gz" | tar xz -C "$BINARYEN_DIR" --strip-components=1 + fi + echo "$BINARYEN_DIR/bin" >> "$GITHUB_PATH" + - name: 🔀 Choose production deployment environment and insert template id: production-env if: github.event_name == 'push' @@ -294,6 +301,7 @@ jobs: env: WASM_BINDGEN_CLI_VERSION: "0.2.121" + BINARYEN_VERSION: "129" steps: - name: 📥 Clone repository @@ -341,13 +349,15 @@ jobs: winget install --id LLVM.LLVM -e --accept-package-agreements --accept-source-agreements winget install --id Kitware.CMake -e --accept-package-agreements --accept-source-agreements winget install --id OpenSSL.OpenSSL -e --accept-package-agreements --accept-source-agreements - winget install --id WebAssembly.Binaryen -e --accept-package-agreements --accept-source-agreements winget install --id GnuWin32.PkgConfig -e --accept-package-agreements --accept-source-agreements "OPENSSL_DIR=C:\Program Files\OpenSSL-Win64" | Out-File -FilePath $env:GITHUB_ENV -Append "PKG_CONFIG_PATH=C:\Program Files\OpenSSL-Win64\lib\pkgconfig" | Out-File -FilePath $env:GITHUB_ENV -Append - cargo binstall --no-confirm --force wasm-pack + curl.exe -sSfL -o "$env:RUNNER_TEMP\binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/version_$env:BINARYEN_VERSION/binaryen-version_$env:BINARYEN_VERSION-x86_64-windows.tar.gz" + tar -xzf "$env:RUNNER_TEMP\binaryen.tar.gz" -C $env:RUNNER_TEMP + "$env:RUNNER_TEMP\binaryen-version_$env:BINARYEN_VERSION\bin" | Out-File -FilePath $env:GITHUB_PATH -Append + cargo binstall --no-confirm --force cargo-about cargo binstall --no-confirm --force "wasm-bindgen-cli@$env:WASM_BINDGEN_CLI_VERSION" @@ -485,6 +495,7 @@ jobs: env: WASM_BINDGEN_CLI_VERSION: "0.2.121" + BINARYEN_VERSION: "129" steps: - name: 📥 Clone repository @@ -529,7 +540,6 @@ jobs: brew install \ pkg-config \ openssl@3 \ - binaryen \ llvm \ cargo-binstall @@ -537,7 +547,10 @@ jobs: echo "PKG_CONFIG_PATH=$(brew --prefix openssl@3)/lib/pkgconfig" >> $GITHUB_ENV echo "$(brew --prefix llvm)/bin" >> $GITHUB_PATH - cargo binstall --no-confirm --force wasm-pack + curl -sSfL -o "$RUNNER_TEMP/binaryen.tar.gz" "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-arm64-macos.tar.gz" + tar -xzf "$RUNNER_TEMP/binaryen.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/binaryen-version_${BINARYEN_VERSION}/bin" >> "$GITHUB_PATH" + cargo binstall --no-confirm --force cargo-about cargo binstall --no-confirm --force "wasm-bindgen-cli@${WASM_BINDGEN_CLI_VERSION}" diff --git a/.nix/dev.nix b/.nix/dev.nix index d7b7f8d123..2288e4a509 100644 --- a/.nix/dev.nix +++ b/.nix/dev.nix @@ -30,7 +30,6 @@ pkgs.mkShell ( pkgs.nodejs pkgs.binaryen pkgs.wasm-bindgen-cli_0_2_121 - pkgs.wasm-pack pkgs.cargo-about pkgs.rustc diff --git a/.nix/pkgs/graphite.nix b/.nix/pkgs/graphite.nix index 51d25e508e..7fe041054d 100644 --- a/.nix/pkgs/graphite.nix +++ b/.nix/pkgs/graphite.nix @@ -84,7 +84,6 @@ deps.crane.lib.buildPackage ( pkgs.nodejs pkgs.binaryen pkgs.wasm-bindgen-cli_0_2_121 - pkgs.wasm-pack pkgs.cargo-about pkgs.removeReferencesTo pkgs.importNpmLock.npmConfigHook @@ -94,7 +93,6 @@ deps.crane.lib.buildPackage ( npmRoot = "${info.src}/frontend"; }; npmRoot = "frontend"; - npmConfigScript = "setup"; makeCacheWritable = true; env = { diff --git a/frontend/.gitignore b/frontend/.gitignore index 12d5b4e4a3..9056a3bde9 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,4 @@ node_modules/ -wasm/pkg/ +wrapper/pkg/ public/build/ dist/ diff --git a/frontend/README.md b/frontend/README.md index 390b63f471..b2946afa1d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -12,7 +12,7 @@ Source code for the web app in the form of Svelte components and [TypeScript](ht ## Editor wrapper: `wrapper/` -Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by [wasm-bindgen](https://rustwasm.github.io/docs/wasm-bindgen/) in concert with [wasm-pack](https://github.com/rustwasm/wasm-pack). +Wraps the editor backend codebase (`/editor`) and provides a JS-centric API for the web app to use as an entry point, unburdened by Rust's complex data types that are incompatible with JS data types. Bindings (JS functions that call into the Wasm module) are provided by `wasm-bindgen`. As part of `cargo run`, our build tool compiles this crate to Wasm, runs the `wasm-bindgen` CLI to generate the JS/TS bindings in `wrapper/pkg/`, and (for release builds) optimizes the binary with Binaryen's `wasm-opt`. ## ESLint configuration: `eslint.config.js` diff --git a/frontend/package-installer.js b/frontend/package-installer.js index 95f5bb6c5d..2caf6df711 100644 --- a/frontend/package-installer.js +++ b/frontend/package-installer.js @@ -1,4 +1,4 @@ -// This script automatically installs the npm packages listed in package-lock.json and runs before `npm start`. +// This script automatically installs the npm packages listed in package-lock.json and runs as part of `npm run setup` (invoked by `cargo run`). // It skips the installation if this has already run and neither package.json nor package-lock.json has been modified since. import { execSync } from "child_process"; diff --git a/frontend/package.json b/frontend/package.json index 442b9a5b95..909aa5cc36 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,25 +6,9 @@ "browserslist": "> 1.5%, last 2 versions, not dead, not ie 11, not op_mini all, not ios_saf < 13", "type": "module", "scripts": { - "---------- DEV SERVER ----------": "", - "start": "npm run setup && npm run wasm:build-dev && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-dev\"", - "production": "npm run setup && npm run wasm:build-production && concurrently -k -n \"VITE,RUST\" \"vite\" \"npm run wasm:watch-production\"", - "---------- BUILDS ----------": "", - "build": "npm run setup && npm run wasm:build-production && vite build", - "build-dev": "npm run setup && npm run wasm:build-dev && vite build --mode dev", - "build-native": "npm run setup && npm run native:build-production", - "build-native-dev": "npm run setup && npm run native:build-dev", - "---------- UTILITIES ----------": "", "check": "svelte-check --fail-on-warnings && eslint", "fix": "eslint --fix", - "---------- INTERNAL ----------": "", - "setup": "node package-installer.js && node branding-installer.js", - "native:build-dev": "wasm-pack build ./wrapper --dev --target=web --no-default-features --features native && vite build --mode native", - "native:build-production": "wasm-pack build ./wrapper --release --target=web --no-default-features --features native && vite build --mode native", - "wasm:build-dev": "wasm-pack build ./wrapper --dev --target=web", - "wasm:build-production": "wasm-pack build ./wrapper --release --target=web", - "wasm:watch-dev": "cargo watch --postpone --watch-when-idle --workdir=wrapper --shell \"wasm-pack build . --dev --target=web -- --color=always\"", - "wasm:watch-production": "cargo watch --postpone --watch-when-idle --workdir=wrapper --shell \"wasm-pack build . --release --target=web -- --color=always\"" + "setup": "node package-installer.js && node branding-installer.js" }, "//": "NOTE: `source-sans-pro` is never to be upgraded to 3.x because that renders 1px above its intended position.", "///": "Waiting on before we can update @eslint/js and eslint to 10.x", diff --git a/frontend/wrapper/Cargo.toml b/frontend/wrapper/Cargo.toml index f69599f664..381c1b6e65 100644 --- a/frontend/wrapper/Cargo.toml +++ b/frontend/wrapper/Cargo.toml @@ -40,22 +40,6 @@ ron = { workspace = true } serde_json = { workspace = true } node-macro = { workspace = true } -[package.metadata.wasm-pack.profile.dev] -wasm-opt = false - -[package.metadata.wasm-pack.profile.dev.wasm-bindgen] -debug-js-glue = true -demangle-name-section = true -dwarf-debug-info = false - -[package.metadata.wasm-pack.profile.release] -wasm-opt = ["-Os", "-g"] - -[package.metadata.wasm-pack.profile.release.wasm-bindgen] -debug-js-glue = false -demangle-name-section = false -dwarf-debug-info = false - [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(wasm_bindgen_unstable_test_coverage)', diff --git a/tools/cargo-run/src/lib.rs b/tools/cargo-run/src/lib.rs index 89d01e069b..2a1177980e 100644 --- a/tools/cargo-run/src/lib.rs +++ b/tools/cargo-run/src/lib.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::process; pub mod requirements; +pub mod wasm; pub enum Action { Run, @@ -69,11 +70,101 @@ pub fn run(command: &str) -> Result<(), Error> { run_from(command, None) } -pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> { +/// Installs the frontend's npm packages and branding assets by running the `setup` script from `frontend/package.json` +pub fn run_frontend_setup() -> Result<(), Error> { let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); let frontend_dir = workspace_dir.join("frontend"); let npm = if cfg!(target_os = "windows") { "npm.cmd" } else { "npm" }; - run_from(&format!("{npm} run {args}"), Some(&frontend_dir)) + run_from(&format!("{npm} run setup"), Some(&frontend_dir)) +} + +/// Runs Vite from the `frontend/` directory by invoking its JS entry point with Node.js directly. +pub fn run_vite_in_frontend_dir(args: &str) -> Result<(), Error> { + let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + let frontend_dir = workspace_dir.join("frontend"); + + // Calling the script avoids npm's `vite.cmd` batch shim, which `cmd.exe` interrupts with a "Terminate batch job (Y/N)?" prompt on Ctrl+C + run_from(&format!("node node_modules/vite/bin/vite.js {args}"), Some(&frontend_dir)) +} + +/// Runs the dev server's process supervisor from the `frontend/` directory, given its program and arguments. +pub fn run_dev_server_in_frontend_dir(program: &str, args: &[&str]) -> Result<(), Error> { + let workspace_dir = std::path::PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + let frontend_dir = workspace_dir.join("frontend"); + + let mut cmd = process::Command::new(program); + cmd.args(args); + cmd.current_dir(&frontend_dir); + + // On Windows, the supervisor is placed in its own process group which doesn't receive the console's Ctrl+C events. + // Instead, a console handler kills the entire process tree at once. A single Ctrl+C thereby shuts everything down + // silently and immediately, rather than letting each descendant process race to react to the event with its own + // error messages and prompts. (Unix terminals already deliver the signal to the whole foreground process group.) + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + + let command_str = format!("{program} {}", args.join(" ")); + let mut child = cmd.spawn().map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))?; + + #[cfg(target_os = "windows")] + ctrl_c_windows::install_handler(child.id()); + + let exit_code = child.wait().map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?; + + #[cfg(target_os = "windows")] + if ctrl_c_windows::interrupted() { + return Ok(()); + } + + if !exit_code.success() { + return Err(Error::Command(command_str, exit_code)); + } + Ok(()) +} + +/// Console Ctrl+C handling for [`run_dev_server_in_frontend_dir`]: terminates the dev server's process tree and +/// reports the interruption so the exit is treated as a success. +#[cfg(target_os = "windows")] +mod ctrl_c_windows { + use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; + + static INTERRUPTED: AtomicBool = AtomicBool::new(false); + static CHILD_PID: AtomicU32 = AtomicU32::new(0); + + #[link(name = "kernel32")] + unsafe extern "system" { + fn SetConsoleCtrlHandler(handler: Option i32>, add: i32) -> i32; + } + + /// Windows runs this on a dedicated thread when the console receives Ctrl+C (or Ctrl+Break, or a window close) + unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 { + INTERRUPTED.store(true, Ordering::SeqCst); + + let pid = CHILD_PID.load(Ordering::SeqCst); + if pid != 0 { + let _ = std::process::Command::new("taskkill") + .args(["/T", "/F", "/PID", &pid.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + } + + // Report the event as handled so the default handler doesn't also terminate this process + 1 + } + + pub fn install_handler(child_pid: u32) { + CHILD_PID.store(child_pid, Ordering::SeqCst); + unsafe { SetConsoleCtrlHandler(Some(ctrl_handler), 1) }; + } + + pub fn interrupted() -> bool { + INTERRUPTED.load(Ordering::SeqCst) + } } pub fn open_url(url: &str) -> Result<(), Error> { diff --git a/tools/cargo-run/src/main.rs b/tools/cargo-run/src/main.rs index 0a840934f3..c02b5fdad5 100644 --- a/tools/cargo-run/src/main.rs +++ b/tools/cargo-run/src/main.rs @@ -33,6 +33,9 @@ fn usage() { } fn main() -> ExitCode { + // Put the managed Binaryen installation (if present) on PATH for this process and its children + requirements::use_managed_binaryen(); + let args: Vec = std::env::args().collect(); let args: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); @@ -85,11 +88,19 @@ fn run_task(task: &Task) -> Result<(), Error> { requirements::check(task)?; match (&task.action, &task.target, &task.profile) { - (Action::Run, Target::Web, Profile::Debug | Profile::Default) => npm_run_in_frontend_dir("start")?, - (Action::Run, Target::Web, Profile::Release) => npm_run_in_frontend_dir("production")?, + (Action::Run, Target::Web, Profile::Debug | Profile::Default) => run_web_dev_server(false)?, + (Action::Run, Target::Web, Profile::Release) => run_web_dev_server(true)?, - (Action::Build, Target::Web, Profile::Debug) => npm_run_in_frontend_dir("build-dev")?, - (Action::Build, Target::Web, Profile::Release | Profile::Default) => npm_run_in_frontend_dir("build")?, + (Action::Build, Target::Web, Profile::Debug) => { + run_frontend_setup()?; + wasm::build(false, false)?; + run_vite_in_frontend_dir("build --mode dev")?; + } + (Action::Build, Target::Web, Profile::Release | Profile::Default) => { + run_frontend_setup()?; + wasm::build(true, false)?; + run_vite_in_frontend_dir("build")?; + } (action, Target::Desktop, mut profile) => { if matches!(profile, Profile::Default) { @@ -100,11 +111,10 @@ fn run_task(task: &Task) -> Result<(), Error> { } } - if matches!(profile, Profile::Release) { - npm_run_in_frontend_dir("build-native")?; - } else { - npm_run_in_frontend_dir("build-native-dev")?; - }; + // Build the editor's Wasm module with the `native` feature, then bundle the frontend with Vite + run_frontend_setup()?; + wasm::build(matches!(profile, Profile::Release), true)?; + run_vite_in_frontend_dir("build --mode native")?; run("cargo run -p third-party-licenses --features desktop")?; @@ -131,3 +141,18 @@ fn run_task(task: &Task) -> Result<(), Error> { } Ok(()) } + +/// Builds the editor's Wasm module, then runs the Vite dev server alongside a `cargo watch` loop that rebuilds the Wasm module when the Rust source changes. +/// The two are run in parallel by `concurrently`, which labels their output and shuts both down when either exits. +/// Both `concurrently` and Vite are invoked through their JS entry points because npm's `.cmd` batch shims trip up Ctrl+C handling on Windows. +fn run_web_dev_server(release: bool) -> Result<(), Error> { + const VITE: &str = "node node_modules/vite/bin/vite.js"; + const CONCURRENTLY: &str = "node_modules/concurrently/dist/bin/concurrently.js"; + + run_frontend_setup()?; + wasm::build(release, false)?; + + let rebuild_steps = wasm::watch_shell_commands(release).iter().map(|step| format!("--shell \"{step}\"")).collect::>().join(" "); + let watcher = format!("cargo watch --postpone --watch-when-idle --workdir=wrapper {rebuild_steps}"); + run_dev_server_in_frontend_dir("node", &[CONCURRENTLY, "-k", "-n", "VITE,RUST", VITE, &watcher]) +} diff --git a/tools/cargo-run/src/requirements.rs b/tools/cargo-run/src/requirements.rs index fd0a3b8374..70110c022f 100644 --- a/tools/cargo-run/src/requirements.rs +++ b/tools/cargo-run/src/requirements.rs @@ -3,13 +3,22 @@ use std::process::Command; use crate::*; +/// The Binaryen release version that [`install_binaryen`] downloads. +/// NOTICE: keep in sync with the `BINARYEN_VERSION` pinned across the CI workflows. +const BINARYEN_VERSION: &str = "129"; +const WASM_OPT_INSTALL: &str = "automatically download Binaryen (wasm-opt) from its official GitHub releases"; + #[derive(Default, Clone)] struct Requirement { command: &'static str, args: &'static [&'static str], name: &'static str, + /// An exact version which must appear in the version output, for tools pinned to one specific version. version: Option<&'static str>, + /// The command to install the tool, or with `install_action` present, a description of what it will do. install: Option<&'static str>, + /// An installation procedure to run instead of executing `install` as a command. + install_action: Option<&'static dyn Fn() -> Result<(), Error>>, skip: Option<&'static dyn Fn(&Task) -> bool>, } @@ -24,7 +33,7 @@ fn requirements(task: &Task) -> Vec { Requirement { command: "cargo-about", args: &["--version"], - name: "cargo-about", + name: "Cargo About", install: Some("cargo install cargo-about"), skip: Some(&|task| matches!(task.target, Target::Cli)), ..Default::default() @@ -32,7 +41,7 @@ fn requirements(task: &Task) -> Vec { Requirement { command: "cargo-watch", args: &["--version"], - name: "cargo-watch", + name: "Cargo Watch", install: Some("cargo install cargo-watch"), skip: Some(&|task| { !matches!( @@ -49,18 +58,28 @@ fn requirements(task: &Task) -> Vec { Requirement { command: "wasm-bindgen", args: &["--version"], - name: "wasm-bindgen-cli", + name: "Wasm Bindgen", version: Some("0.2.121"), install: Some("cargo install -f wasm-bindgen-cli@0.2.121"), skip: Some(&|task| matches!(task.target, Target::Cli)), + ..Default::default() }, Requirement { - command: "wasm-pack", + command: "wasm-opt", args: &["--version"], - name: "wasm-pack", - install: Some("cargo install wasm-pack"), - skip: Some(&|task| matches!(task.target, Target::Cli)), - ..Default::default() + name: "Wasm Opt", + version: Some(BINARYEN_VERSION), + install: Some(WASM_OPT_INSTALL), + install_action: Some(&install_binaryen), + // Only release builds are optimized with wasm-opt + skip: Some(&|task| { + matches!(task.target, Target::Cli) + || match task.profile { + Profile::Debug => true, + Profile::Release => false, + Profile::Default => matches!(task.action, Action::Run), + } + }), }, Requirement { command: "node", @@ -185,8 +204,17 @@ pub fn check(task: &Task) -> Result<(), Error> { if input.is_empty() || input.eq_ignore_ascii_case("y") || input.eq_ignore_ascii_case("yes") { for dep in &installable { - let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect(); eprintln!("Running: {}...", dep.install.unwrap()); + + if let Some(action) = dep.install_action { + if let Err(e) = action() { + eprintln!("{e}"); + eprintln!("Failed to install {}", dep.name); + } + continue; + } + + let parts: Vec<&str> = dep.install.unwrap().split_whitespace().collect(); let status = Command::new(parts[0]) .args(&parts[1..]) .status() @@ -198,3 +226,56 @@ pub fn check(task: &Task) -> Result<(), Error> { } Ok(()) } + +/// Downloads the pinned Binaryen release into the workspace's target directory and puts its tools on this process's PATH. +/// Windows, Mac, and Linux all ship with `curl` and `tar`, so no package manager is needed. +fn install_binaryen() -> Result<(), Error> { + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86_64") => "x86_64-windows", + ("macos", "aarch64") => "arm64-macos", + ("macos", "x86_64") => "x86_64-macos", + ("linux", "x86_64") => "x86_64-linux", + ("linux", "aarch64") => "aarch64-linux", + (os, arch) => { + let error = std::io::Error::other(format!("no official Binaryen release exists for {os} on {arch}")); + return Err(Error::Io(error, "Failed to download Binaryen".into())); + } + }; + + let target_dir = wasm::target_dir(); + std::fs::create_dir_all(&target_dir).map_err(|e| Error::Io(e, format!("Failed to create directory '{}'", target_dir.display())))?; + + let url = format!("https://github.com/WebAssembly/binaryen/releases/download/version_{BINARYEN_VERSION}/binaryen-version_{BINARYEN_VERSION}-{platform}.tar.gz"); + let tarball = target_dir.join("binaryen.tar.gz"); + + let mut download = Command::new("curl"); + download.args(["-sSfL", &url, "-o"]).arg(&tarball); + wasm::run_command(download)?; + + let mut extract = Command::new("tar"); + extract.arg("-xzf").arg(&tarball).arg("-C").arg(&target_dir); + wasm::run_command(extract)?; + + let _ = std::fs::remove_file(&tarball); + + use_managed_binaryen(); + Ok(()) +} + +/// Prepends the managed Binaryen installation (if present) to this process's PATH, which child processes inherit. +/// Prepending lets the pinned version win over any other installed wasm-opt. +pub fn use_managed_binaryen() { + let bin_dir = wasm::target_dir().join(format!("binaryen-version_{BINARYEN_VERSION}")).join("bin"); + if !bin_dir.is_dir() { + return; + } + + let mut paths = vec![bin_dir]; + if let Some(path) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&path)); + } + if let Ok(joined) = std::env::join_paths(paths) { + // Safety: this runs before any other threads are spawned + unsafe { std::env::set_var("PATH", joined) }; + } +} diff --git a/tools/cargo-run/src/wasm.rs b/tools/cargo-run/src/wasm.rs new file mode 100644 index 0000000000..2117618097 --- /dev/null +++ b/tools/cargo-run/src/wasm.rs @@ -0,0 +1,125 @@ +use crate::Error; +use std::path::PathBuf; +use std::process::Command; + +const WRAPPER_CRATE: &str = "graphite-wasm-wrapper"; +const WASM_TARGET: &str = "wasm32-unknown-unknown"; +const OUT_NAME: &str = "graphite_wasm_wrapper"; +const WASM_OPT_ARGS: &[&str] = &["-Os", "-g"]; + +/// Builds the editor's Wasm module (`/frontend/wrapper`) by running `cargo build`, then `wasm-bindgen` to generate the JS/TS bindings +/// and final `.wasm` binary in `/frontend/wrapper/pkg`, then (for release builds only) `wasm-opt` to optimize the binary for size. +pub fn build(release: bool, native: bool) -> Result<(), Error> { + let workspace_dir = PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + + // Ensure the Wasm compilation target is installed (quietly skipped where `rustup` isn't used, like Nix) + ensure_wasm_target_installed(); + + // Compile the wrapper crate to Wasm + let mut cargo_build = Command::new("cargo"); + cargo_build.current_dir(&workspace_dir); + cargo_build.args(cargo_build_args(release, native)); + run_command(cargo_build)?; + + // Generate the JS/TS bindings and the processed `.wasm` binary in `/frontend/wrapper/pkg` + let wasm_artifact = target_dir().join(WASM_TARGET).join(if release { "release" } else { "debug" }).join(format!("{OUT_NAME}.wasm")); + let pkg_dir = workspace_dir.join("frontend").join("wrapper").join("pkg"); + + let mut wasm_bindgen = Command::new("wasm-bindgen"); + wasm_bindgen.args(wasm_bindgen_args(release)); + wasm_bindgen.arg("--out-dir").arg(&pkg_dir); + wasm_bindgen.arg(&wasm_artifact); + run_command(wasm_bindgen)?; + + // Optimize the binary for size, keeping the name section (`-g`) for usable stack traces and profiling + if release { + let wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.wasm")); + let optimized_wasm_file = pkg_dir.join(format!("{OUT_NAME}_bg.opt.wasm")); + + let mut wasm_opt = Command::new("wasm-opt"); + wasm_opt.args(WASM_OPT_ARGS).arg(&wasm_file).arg("-o").arg(&optimized_wasm_file); + run_command(wasm_opt)?; + + std::fs::rename(&optimized_wasm_file, &wasm_file).map_err(|e| Error::Io(e, "Failed to move the wasm-opt output into place".into()))?; + } + + Ok(()) +} + +/// Renders the same rebuild steps as [`build`] into shell commands for the dev server's `cargo watch` loop, which +/// runs them in sequence from the wrapper crate's directory on every Rust source change. Invoking the tools directly +/// keeps this tool's own binary out of the loop (rebuilding a running executable fails on Windows). The commands must +/// stay free of quotes and spaces in paths (hence the relative paths), since each one is wrapped in quotes that must +/// survive both `cmd.exe` and `sh` on the way to `cargo watch`. +pub fn watch_shell_commands(release: bool) -> Vec { + let profile_dir = if release { "release" } else { "debug" }; + + let mut steps = vec![ + format!("cargo {} --color=always", cargo_build_args(release, false).join(" ")), + format!( + "wasm-bindgen {} --out-dir pkg ../../target/{WASM_TARGET}/{profile_dir}/{OUT_NAME}.wasm", + wasm_bindgen_args(release).join(" ") + ), + ]; + if release { + // Optimized in place, which is safe because wasm-opt fully reads the input before writing the output + steps.push(format!("wasm-opt {} pkg/{OUT_NAME}_bg.wasm -o pkg/{OUT_NAME}_bg.wasm", WASM_OPT_ARGS.join(" "))); + } + steps +} + +/// The `cargo build` arguments shared by [`build`] and [`watch_shell_commands`]. +fn cargo_build_args(release: bool, native: bool) -> Vec<&'static str> { + let mut args = vec!["build", "--lib", "--package", WRAPPER_CRATE, "--target", WASM_TARGET]; + if release { + args.push("--release"); + } + if native { + args.extend(["--no-default-features", "--features", "native"]); + } + args +} + +/// The `wasm-bindgen` arguments shared by [`build`] and [`watch_shell_commands`], except the input/output paths which differ by context. +fn wasm_bindgen_args(release: bool) -> Vec<&'static str> { + let mut args = vec!["--target", "web", "--out-name", OUT_NAME]; + if release { + // Don't demangle Rust symbol names in the name section, saving some space in production builds + args.push("--no-demangle"); + } else { + // Include runtime assertions in the generated JS glue code to catch incorrect usage during development + args.push("--debug"); + } + args +} + +/// The workspace's cargo target directory, honoring the `CARGO_TARGET_DIR` environment variable. +pub(crate) fn target_dir() -> PathBuf { + let workspace_dir = PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| workspace_dir.join("target")) +} + +/// Installs the Wasm target through rustup if it's missing. Any failure is ignored because rustup may not exist in +/// environments that preinstall the target (such as Nix); an actual missing target surfaces as a `cargo build` error instead. +fn ensure_wasm_target_installed() { + let Ok(output) = Command::new("rustup").args(["target", "list", "--installed"]).output() else { + return; + }; + if !output.status.success() || String::from_utf8_lossy(&output.stdout).lines().any(|line| line.trim() == WASM_TARGET) { + return; + } + let _ = Command::new("rustup").args(["target", "add", WASM_TARGET]).status(); +} + +pub(crate) fn run_command(mut command: Command) -> Result<(), Error> { + let command_str = format!("{command:?}"); + let exit_code = command + .spawn() + .map_err(|e| Error::Io(e, format!("Failed to spawn command {command_str}")))? + .wait() + .map_err(|e| Error::Io(e, format!("Failed to wait for command {command_str}")))?; + if !exit_code.success() { + return Err(Error::Command(command_str, exit_code)); + } + Ok(()) +} From 952460784d3b57a995a26d9250c05e13b24b632e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 12 Jun 2026 01:32:21 -0700 Subject: [PATCH 3/5] Fix npm package installation omitting build tooling when NODE_ENV=production The devDependencies hold the build tooling (Vite, etc.), which npm omits in environments that set NODE_ENV=production, like CI does for the Vite build. The install timestamp check now also covers package-installer.js itself so changes to the install process trigger a reinstall. --- frontend/package-installer.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/package-installer.js b/frontend/package-installer.js index 2caf6df711..2a924eb29d 100644 --- a/frontend/package-installer.js +++ b/frontend/package-installer.js @@ -11,7 +11,8 @@ const isInstallNeeded = () => { if (!existsSync(INSTALL_TIMESTAMP_FILE)) return true; const timestamp = statSync(INSTALL_TIMESTAMP_FILE).mtime; - return ["package.json", "package-lock.json"].some((file) => { + // This script is itself included so that changes to the install process below cause a reinstall + return ["package.json", "package-lock.json", "package-installer.js"].some((file) => { return existsSync(file) && statSync(file).mtime > timestamp; }); }; @@ -22,8 +23,10 @@ if (isInstallNeeded()) { // eslint-disable-next-line no-console console.log("Installing npm packages..."); - // Check if packages are up to date, doing so quickly by using `npm ci`, preferring local cached packages, and skipping the package audit and other checks - execSync("npm ci --prefer-offline --no-audit --no-fund", { stdio: "inherit" }); + // Check if packages are up to date, doing so quickly by using `npm ci`, preferring local cached packages, and skipping the package audit and other checks. + // The devDependencies are explicitly included because they hold the build tooling (Vite, etc.), which npm would + // otherwise omit in environments that set NODE_ENV=production (like CI does for the sake of the Vite build). + execSync("npm ci --include=dev --prefer-offline --no-audit --no-fund", { stdio: "inherit" }); // Touch the install timestamp file writeFileSync(INSTALL_TIMESTAMP_FILE, ""); From 9b5152e8bfc8fd40e079a0d4c9ed52bc4c355bce Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 12 Jun 2026 02:00:42 -0700 Subject: [PATCH 4/5] Address code review feedback for cargo-run tooling --- tools/cargo-run/src/lib.rs | 14 +++++- tools/cargo-run/src/requirements.rs | 78 ++++++++++++++++++++++++++++- tools/cargo-run/src/wasm.rs | 17 ++++++- 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/tools/cargo-run/src/lib.rs b/tools/cargo-run/src/lib.rs index 2a1177980e..132f887225 100644 --- a/tools/cargo-run/src/lib.rs +++ b/tools/cargo-run/src/lib.rs @@ -96,6 +96,12 @@ pub fn run_dev_server_in_frontend_dir(program: &str, args: &[&str]) -> Result<() cmd.args(args); cmd.current_dir(&frontend_dir); + // Cargo resolves a relative `CARGO_TARGET_DIR` against its working directory, which differs inside the watch + // loop, so descendant processes get the absolute form + if std::env::var_os("CARGO_TARGET_DIR").is_some() { + cmd.env("CARGO_TARGET_DIR", wasm::target_dir()); + } + // On Windows, the supervisor is placed in its own process group which doesn't receive the console's Ctrl+C events. // Instead, a console handler kills the entire process tree at once. A single Ctrl+C thereby shuts everything down // silently and immediately, rather than letting each descendant process race to react to the event with its own @@ -158,8 +164,14 @@ mod ctrl_c_windows { } pub fn install_handler(child_pid: u32) { + INTERRUPTED.store(false, Ordering::SeqCst); CHILD_PID.store(child_pid, Ordering::SeqCst); - unsafe { SetConsoleCtrlHandler(Some(ctrl_handler), 1) }; + + // Guards against the handler being registered (and thereby invoked) multiple times + static REGISTER: std::sync::Once = std::sync::Once::new(); + REGISTER.call_once(|| { + unsafe { SetConsoleCtrlHandler(Some(ctrl_handler), 1) }; + }); } pub fn interrupted() -> bool { diff --git a/tools/cargo-run/src/requirements.rs b/tools/cargo-run/src/requirements.rs index 70110c022f..009576339a 100644 --- a/tools/cargo-run/src/requirements.rs +++ b/tools/cargo-run/src/requirements.rs @@ -4,8 +4,17 @@ use std::process::Command; use crate::*; /// The Binaryen release version that [`install_binaryen`] downloads. -/// NOTICE: keep in sync with the `BINARYEN_VERSION` pinned across the CI workflows. +/// NOTICE: keep in sync with the `BINARYEN_VERSION` pinned across the CI workflows, and update `BINARYEN_SHA256` below. const BINARYEN_VERSION: &str = "129"; +/// The SHA-256 checksums of the pinned Binaryen release's tarballs, from the `.sha256` assets published beside them. +const BINARYEN_SHA256: &[(&str, &str)] = &[ + ("x86_64-windows", "1405d2f51377859ccf5fcd2c59c0a8c5756373e691ca0eeb5219f646b743e3aa"), + ("arm64-windows", "40db97baf6aa7c0d9d105a5745572a7a92ed345358b86ecf59f5410e9b2a856e"), + ("arm64-macos", "d1bb014775ca3002506712b81b4406d126ff6845e8b2f343bc2696a1a88b7117"), + ("x86_64-macos", "cc38897d3d93c968f24819fae210e04afd0146d0e2467e307207ea7e798a59b9"), + ("x86_64-linux", "50b9fa62b9abea752da92ec57e0c555fee578760cd237c40107957715d2976ba"), + ("aarch64-linux", "81d46b86b10876ab615eec67e09fcc5615115a7b189cfe3d466725ee36c46ac2"), +]; const WASM_OPT_INSTALL: &str = "automatically download Binaryen (wasm-opt) from its official GitHub releases"; #[derive(Default, Clone)] @@ -232,6 +241,7 @@ pub fn check(task: &Task) -> Result<(), Error> { fn install_binaryen() -> Result<(), Error> { let platform = match (std::env::consts::OS, std::env::consts::ARCH) { ("windows", "x86_64") => "x86_64-windows", + ("windows", "aarch64") => "arm64-windows", ("macos", "aarch64") => "arm64-macos", ("macos", "x86_64") => "x86_64-macos", ("linux", "x86_64") => "x86_64-linux", @@ -252,6 +262,14 @@ fn install_binaryen() -> Result<(), Error> { download.args(["-sSfL", &url, "-o"]).arg(&tarball); wasm::run_command(download)?; + let expected_sha256 = BINARYEN_SHA256.iter().find(|(p, _)| *p == platform).map(|(_, sha256)| *sha256); + let expected_sha256 = expected_sha256.expect("Every supported platform has a pinned checksum"); + if let Err(error) = verify_sha256(&tarball, expected_sha256) { + let _ = std::fs::remove_file(&tarball); + print_published_binaryen_sha256(); + return Err(error); + } + let mut extract = Command::new("tar"); extract.arg("-xzf").arg(&tarball).arg("-C").arg(&target_dir); wasm::run_command(extract)?; @@ -262,6 +280,64 @@ fn install_binaryen() -> Result<(), Error> { Ok(()) } +/// Prints a copy-pastable replacement for `BINARYEN_SHA256`, populated with the hashes published in the pinned +/// release, for updating the code after bumping `BINARYEN_VERSION`. +fn print_published_binaryen_sha256() { + eprintln!(); + eprintln!("If `BINARYEN_VERSION` was just changed, update `BINARYEN_SHA256` with the hashes published in the release:"); + eprintln!("const BINARYEN_SHA256: &[(&str, &str)] = &["); + for (platform, _) in BINARYEN_SHA256 { + let url = format!("https://github.com/WebAssembly/binaryen/releases/download/version_{BINARYEN_VERSION}/binaryen-version_{BINARYEN_VERSION}-{platform}.tar.gz.sha256"); + let published = Command::new("curl") + .args(["-sSfL", &url]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8_lossy(&output.stdout).split_whitespace().next().map(str::to_string)); + eprintln!("\t(\"{platform}\", \"{}\"),", published.unwrap_or_else(|| "FAILED TO FETCH".to_string())); + } + eprintln!("];"); +} + +/// Verifies a file's SHA-256 checksum using the hashing tool that ships with each OS. +fn verify_sha256(file: &std::path::Path, expected: &str) -> Result<(), Error> { + let mut hash = match std::env::consts::OS { + "windows" => { + let mut hash = Command::new("certutil"); + hash.arg("-hashfile").arg(file).arg("SHA256"); + hash + } + "macos" => { + let mut hash = Command::new("shasum"); + hash.args(["-a", "256"]).arg(file); + hash + } + _ => { + let mut hash = Command::new("sha256sum"); + hash.arg(file); + hash + } + }; + + let command_str = format!("{hash:?}"); + let output = hash.output().map_err(|e| Error::Io(e, format!("Failed to run command {command_str}")))?; + if !output.status.success() { + return Err(Error::Command(command_str, output.status)); + } + + // The checksum is the output's only token of 64 hex digits, robust to each tool's surrounding text + let stdout = String::from_utf8_lossy(&output.stdout); + let found = stdout.split_whitespace().find(|token| token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit())); + + if found.is_none_or(|found| !found.eq_ignore_ascii_case(expected)) { + eprintln!("Hash mismatch for '{}'!", file.display()); + eprintln!("Expected: {expected}"); + eprintln!("Actual: {}", found.unwrap_or("(none)")); + return Err(Error::Io(std::io::Error::other("hash mismatch"), "Failed to verify the Binaryen download".into())); + } + Ok(()) +} + /// Prepends the managed Binaryen installation (if present) to this process's PATH, which child processes inherit. /// Prepending lets the pinned version win over any other installed wasm-opt. pub fn use_managed_binaryen() { diff --git a/tools/cargo-run/src/wasm.rs b/tools/cargo-run/src/wasm.rs index 2117618097..9c3744d5fb 100644 --- a/tools/cargo-run/src/wasm.rs +++ b/tools/cargo-run/src/wasm.rs @@ -54,10 +54,18 @@ pub fn build(release: bool, native: bool) -> Result<(), Error> { pub fn watch_shell_commands(release: bool) -> Vec { let profile_dir = if release { "release" } else { "debug" }; + // Expressed relative to the wrapper directory when inside the workspace (always true unless `CARGO_TARGET_DIR` + // points elsewhere), avoiding absolute path prefixes which may contain spaces + let workspace_dir = PathBuf::from(env!("CARGO_WORKSPACE_DIR")); + let target_dir = match target_dir().strip_prefix(&workspace_dir) { + Ok(within_workspace) => format!("../../{}", within_workspace.display()), + Err(_) => target_dir().display().to_string(), + }; + let mut steps = vec![ format!("cargo {} --color=always", cargo_build_args(release, false).join(" ")), format!( - "wasm-bindgen {} --out-dir pkg ../../target/{WASM_TARGET}/{profile_dir}/{OUT_NAME}.wasm", + "wasm-bindgen {} --out-dir pkg {target_dir}/{WASM_TARGET}/{profile_dir}/{OUT_NAME}.wasm", wasm_bindgen_args(release).join(" ") ), ]; @@ -96,7 +104,12 @@ fn wasm_bindgen_args(release: bool) -> Vec<&'static str> { /// The workspace's cargo target directory, honoring the `CARGO_TARGET_DIR` environment variable. pub(crate) fn target_dir() -> PathBuf { let workspace_dir = PathBuf::from(env!("CARGO_WORKSPACE_DIR")); - std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| workspace_dir.join("target")) + match std::env::var_os("CARGO_TARGET_DIR") { + // Joining handles both forms: an absolute path replaces the workspace prefix entirely, while a relative path + // is resolved against the workspace root, matching how cargo resolves it when invoked from there + Some(custom_dir) => workspace_dir.join(custom_dir), + None => workspace_dir.join("target"), + } } /// Installs the Wasm target through rustup if it's missing. Any failure is ignored because rustup may not exist in From fd34596855357ae709aaecec54190b654747a259 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Fri, 12 Jun 2026 08:59:13 -0700 Subject: [PATCH 5/5] Upgrade to wasm-opt 130 --- .github/workflows/build.yml | 6 +++--- tools/cargo-run/src/requirements.rs | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 69d607305a..ebd0889421 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,7 +59,7 @@ jobs: CARGO_INCREMENTAL: 0 SCCACHE_DIR: /var/lib/github-actions/.cache WASM_BINDGEN_CLI_VERSION: "0.2.121" - BINARYEN_VERSION: "129" + BINARYEN_VERSION: "130" steps: - name: 📥 Clone repository uses: actions/checkout@v6 @@ -301,7 +301,7 @@ jobs: env: WASM_BINDGEN_CLI_VERSION: "0.2.121" - BINARYEN_VERSION: "129" + BINARYEN_VERSION: "130" steps: - name: 📥 Clone repository @@ -495,7 +495,7 @@ jobs: env: WASM_BINDGEN_CLI_VERSION: "0.2.121" - BINARYEN_VERSION: "129" + BINARYEN_VERSION: "130" steps: - name: 📥 Clone repository diff --git a/tools/cargo-run/src/requirements.rs b/tools/cargo-run/src/requirements.rs index 009576339a..5a7b3190ca 100644 --- a/tools/cargo-run/src/requirements.rs +++ b/tools/cargo-run/src/requirements.rs @@ -5,15 +5,15 @@ use crate::*; /// The Binaryen release version that [`install_binaryen`] downloads. /// NOTICE: keep in sync with the `BINARYEN_VERSION` pinned across the CI workflows, and update `BINARYEN_SHA256` below. -const BINARYEN_VERSION: &str = "129"; +const BINARYEN_VERSION: &str = "130"; /// The SHA-256 checksums of the pinned Binaryen release's tarballs, from the `.sha256` assets published beside them. const BINARYEN_SHA256: &[(&str, &str)] = &[ - ("x86_64-windows", "1405d2f51377859ccf5fcd2c59c0a8c5756373e691ca0eeb5219f646b743e3aa"), - ("arm64-windows", "40db97baf6aa7c0d9d105a5745572a7a92ed345358b86ecf59f5410e9b2a856e"), - ("arm64-macos", "d1bb014775ca3002506712b81b4406d126ff6845e8b2f343bc2696a1a88b7117"), - ("x86_64-macos", "cc38897d3d93c968f24819fae210e04afd0146d0e2467e307207ea7e798a59b9"), - ("x86_64-linux", "50b9fa62b9abea752da92ec57e0c555fee578760cd237c40107957715d2976ba"), - ("aarch64-linux", "81d46b86b10876ab615eec67e09fcc5615115a7b189cfe3d466725ee36c46ac2"), + ("x86_64-windows", "cc09c874f4332d00aa32ab72745a9b98c9a172f795762f21d03e70638a3f7f4c"), + ("arm64-windows", "b18c9cbe000562b1ee5d9cb60146616a949aca504903ad63f27fd9fd679898a7"), + ("arm64-macos", "79d3ab9f417d9e215f15f598f523d001a7d9ac1e59367e5c869fbdabd1cba72e"), + ("x86_64-macos-14", "d3e2d1235b70c93c54b52eabc1625ea960965152218754f1f4eeb0f873c48e03"), + ("x86_64-linux", "0a18362361ad05465118cd8eeb72edaeec89de6894bc283576ef4e07aa3babcc"), + ("aarch64-linux", "e6ae6e09ac40f4e14bc5be6f687c58e2995c84170013975fa641809dd3b480a0"), ]; const WASM_OPT_INSTALL: &str = "automatically download Binaryen (wasm-opt) from its official GitHub releases"; @@ -243,7 +243,7 @@ fn install_binaryen() -> Result<(), Error> { ("windows", "x86_64") => "x86_64-windows", ("windows", "aarch64") => "arm64-windows", ("macos", "aarch64") => "arm64-macos", - ("macos", "x86_64") => "x86_64-macos", + ("macos", "x86_64") => "x86_64-macos-14", ("linux", "x86_64") => "x86_64-linux", ("linux", "aarch64") => "aarch64-linux", (os, arch) => {