From c507b356453361e31638b8bff8f6d46b6da2961e Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 16 Aug 2026 02:24:15 -0700 Subject: [PATCH] Add rank-0 Item leaf variants to the Graphic enum alongside its List variants (#4437) * Add the rank-0 Item leaf variants to the Graphic enum, rendering leaves and list rows through shared per-row logic * Traverse the rank-0 Graphic leaf variants in the vector node helpers and flattening * Reach rank-0 vector graphics from the styling and gradient-fitting helpers * Two code review fixes * Fix comment --- .../data_panel/data_panel_message_handler.rs | 32 +- .../document/document_message_handler.rs | 6 +- node-graph/libraries/core-types/src/lib.rs | 1 + node-graph/libraries/core-types/src/list.rs | 13 + node-graph/libraries/core-types/src/none.rs | 9 + .../core-types/src/render_complexity.rs | 8 +- .../libraries/graphic-types/src/appearance.rs | 2 +- .../libraries/graphic-types/src/graphic.rs | 467 ++- .../libraries/rendering/src/render_ext.rs | 210 +- .../libraries/rendering/src/renderer.rs | 3151 +++++++++-------- node-graph/nodes/graphic/src/graphic.rs | 12 +- node-graph/nodes/path-bool/src/lib.rs | 15 +- node-graph/nodes/vector/src/vector_nodes.rs | 151 +- 13 files changed, 2412 insertions(+), 1665 deletions(-) create mode 100644 node-graph/libraries/core-types/src/none.rs diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index a7fd8c9de8..73053ed3e7 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -571,13 +571,33 @@ impl TableItemLayout for BoxCorners { } } +impl TableItemLayout for graphene_std::core_types::none::None { + fn type_name() -> &'static str { + "None" + } + fn identifier(&self) -> String { + "None".to_string() + } + fn value_page(&self, _data: &mut LayoutData) -> Vec { + label("None") + } +} + impl TableItemLayout for Graphic { fn type_name() -> &'static str { "Graphic" } fn identifier(&self) -> String { match self { - Self::None => "None".to_string(), + Self::None(item) => item.identifier(), + Self::Graphic(item) => item.identifier(), + Self::Vector(item) => item.identifier(), + Self::RasterCPU(item) => item.identifier(), + Self::RasterGPU(item) => item.identifier(), + Self::Color(item) => item.identifier(), + Self::Gradient(item) => item.identifier(), + Self::Text(item) => item.identifier(), + Self::NoneList(list) => list.identifier(), Self::GraphicList(list) => list.identifier(), Self::VectorList(list) => list.identifier(), Self::RasterCPUList(list) => list.identifier(), @@ -593,7 +613,15 @@ impl TableItemLayout for Graphic { } fn value_page(&self, data: &mut LayoutData) -> Vec { match self { - Self::None => label("None"), + Self::None(item) => item.layout_with_breadcrumb(data), + Self::Graphic(item) => item.layout_with_breadcrumb(data), + Self::Vector(item) => item.layout_with_breadcrumb(data), + Self::RasterCPU(item) => item.layout_with_breadcrumb(data), + Self::RasterGPU(item) => item.layout_with_breadcrumb(data), + Self::Color(item) => item.layout_with_breadcrumb(data), + Self::Gradient(item) => item.layout_with_breadcrumb(data), + Self::Text(item) => item.layout_with_breadcrumb(data), + Self::NoneList(list) => list.layout_with_breadcrumb(data), Self::GraphicList(list) => list.layout_with_breadcrumb(data), Self::VectorList(list) => list.layout_with_breadcrumb(data), Self::RasterCPUList(list) => list.layout_with_breadcrumb(data), diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 0e58cb87a8..5358bd861f 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -2760,7 +2760,7 @@ impl DocumentMessageHandler { // A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something let has_stroke = appearance.is_some_and(|appearance| { appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke()) - && appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_fully_transparent()) + && appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_guaranteed_fully_transparent()) }); // No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip. @@ -4326,7 +4326,7 @@ mod document_message_handler_tests { let instrumented = editor.eval_graph().await.unwrap(); - // The emptiness guards keep these assertions honest: a wrong `Output` type on `grab_all_input` yields no records at all, which would otherwise pass vacuously + // The emptiness guards keep these assertions honest: a wrong `Output` type on `grab_all_input` yields no records at all, which would otherwise pass without checking anything let base_lengths: Vec = instrumented .grab_all_input::>(&editor.runtime) .map(|base| base.len()) @@ -4341,7 +4341,7 @@ mod document_message_handler_tests { let phantom_count = news .iter() .flat_map(|new| new.iter_element_values()) - .filter(|graphic| matches!(graphic, graphene_std::Graphic::None)) + .filter(|graphic| matches!(graphic, graphene_std::Graphic::None(_))) .count(); assert_eq!(phantom_count, 0, "No stacked element should be a phantom None graphic"); } diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 5cf3ffaf66..01b53ca24d 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -8,6 +8,7 @@ pub mod list; pub mod math; pub mod memo; pub mod misc; +pub mod none; pub mod ops; pub mod registry; pub mod render_complexity; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index 9bcb006989..e34bf699ff 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -1331,6 +1331,19 @@ pub struct Item { attributes: ItemAttributeValues, } +impl BoundingBox for Item { + /// Computes the element's bounding box, composing the item's transform attribute with the given transform. + fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM); + self.element().bounding_box(transform * item_transform, include_stroke) + } + + fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM); + self.element().thumbnail_bounding_box(transform * item_transform, include_stroke) + } +} + impl Default for Item { fn default() -> Self { Self::new_from_element(T::default()) diff --git a/node-graph/libraries/core-types/src/none.rs b/node-graph/libraries/core-types/src/none.rs new file mode 100644 index 0000000000..e99012fbeb --- /dev/null +++ b/node-graph/libraries/core-types/src/none.rs @@ -0,0 +1,9 @@ +use dyn_any::DynAny; +use graphene_hash::CacheHash; + +/// An artist's declaration that there is no content here, distinct from the `()` type's "nothing was wired". +/// Visually represented as a red slash over a white background. Akin to the CSS `none` keyword. +/// +/// Because its name matches the Rust prelude's `Option::None` variant, we always reference this as `none::None`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, CacheHash, DynAny)] +pub struct None; diff --git a/node-graph/libraries/core-types/src/render_complexity.rs b/node-graph/libraries/core-types/src/render_complexity.rs index 691c644aa4..b9bbeeb201 100644 --- a/node-graph/libraries/core-types/src/render_complexity.rs +++ b/node-graph/libraries/core-types/src/render_complexity.rs @@ -1,6 +1,6 @@ // Raster types moved to raster-types crate use crate::Color; -use crate::list::List; +use crate::list::{Item, List}; pub trait RenderComplexity { fn render_complexity(&self) -> usize { @@ -8,6 +8,12 @@ pub trait RenderComplexity { } } +impl RenderComplexity for Item { + fn render_complexity(&self) -> usize { + self.element().render_complexity() + } +} + impl RenderComplexity for List { fn render_complexity(&self) -> usize { self.iter_element_values().map(|element| element.render_complexity()).fold(0, usize::saturating_add) diff --git a/node-graph/libraries/graphic-types/src/appearance.rs b/node-graph/libraries/graphic-types/src/appearance.rs index cb74eebc59..0dee4c3eac 100644 --- a/node-graph/libraries/graphic-types/src/appearance.rs +++ b/node-graph/libraries/graphic-types/src/appearance.rs @@ -460,7 +460,7 @@ mod tests { #[test] fn painted_cover_distinguishes_none_paint_from_absence() { let mut appearance = Appearance::default(); - appearance.replace_or_insert(Coverage::new_fill(), Graphic::None, CoverPlacement::Above); + appearance.replace_or_insert(Coverage::new_fill(), Graphic::default(), CoverPlacement::Above); assert!(appearance.has_cover(Cover::Fill), "a none-painted coverage still exists"); assert!(!appearance.has_painted_cover(Cover::Fill), "a none-painted coverage draws nothing"); diff --git a/node-graph/libraries/graphic-types/src/graphic.rs b/node-graph/libraries/graphic-types/src/graphic.rs index 77df5fe8d3..6cc985332b 100644 --- a/node-graph/libraries/graphic-types/src/graphic.rs +++ b/node-graph/libraries/graphic-types/src/graphic.rs @@ -3,22 +3,30 @@ use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::graphene_hash::CacheHash; use core_types::list::{ATTR_APPEARANCE, ATTR_PAINT, Item, ItemAttributeValues, List, NodeIdPath}; use core_types::math::quad::Quad; +use core_types::none; use core_types::ops::FromAnchorPosition; use core_types::render_complexity::RenderComplexity; use core_types::transform::Transform; -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_SPREAD, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color}; use dyn_any::DynAny; use glam::{DAffine2, DVec2}; use raster_types::{CPU, GPU, Raster}; -use vector_types::Gradient; pub use vector_types::Vector; +use vector_types::{Gradient, GradientSpread}; -/// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax. -#[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)] +/// The possible forms of graphical content that can be rendered by the Render node (to targets like SVG and raster) or another render boundary node. +#[derive(Clone, Debug, CacheHash, PartialEq, DynAny)] pub enum Graphic { - /// The absence of graphical content, like CSS's `none` keyword: painting it produces nothing. - #[default] - None, + /// No content, akin to CSS `none`, represented visually by a red slash. + None(Item), + Graphic(Box>), + Vector(Box>), + RasterCPU(Box>>), + RasterGPU(Item>), + Color(Item), + Gradient(Item), + Text(Item), + NoneList(List), GraphicList(List), VectorList(List), RasterCPUList(List>), @@ -28,6 +36,12 @@ pub enum Graphic { TextList(List), } +impl Default for Graphic { + fn default() -> Self { + Graphic::None(Item::default()) + } +} + // GraphicList impl From> for Graphic { fn from(graphic: List) -> Self { @@ -117,7 +131,7 @@ impl From> for Graphic { /// collapses no structure and rebuilding or snapshotting the result would be busywork. pub fn is_lone_anonymous_leaf(content: &List) -> bool { content.len() == 1 - && !matches!(content.element(0), Some(Graphic::GraphicList(_))) + && !matches!(content.element(0), Some(Graphic::Graphic(_)) | Some(Graphic::GraphicList(_))) && content.attribute::(ATTR_TRANSFORM, 0).is_none() && content.attribute::(ATTR_OPACITY, 0).is_none() && content.attribute::(ATTR_OPACITY_FILL, 0).is_none() @@ -137,9 +151,9 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) fn flatten_recursive(output: &mut List, current_graphic_list: List, extract_variant: fn(Graphic) -> Option>) { for current_graphic_item in current_graphic_list.into_iter() { - // Whether the parent carries each composed attribute: a structural fact (column presence), never a value comparison. + // Whether the parent carries each composed attribute: a structural fact (attribute 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. + // so an absent parent attribute never invents an attribute the children didn't already have. 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(); @@ -151,12 +165,18 @@ fn flatten_graphic_list(content: List, extract_variant: fn(Graphic) 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_item.into_element() { + // A boxed single graphic is the rank-0 spelling of the same nesting, so it flattens through the list path + let current_element = match current_graphic_item.into_element() { + Graphic::Graphic(item) => Graphic::GraphicList(List::new_from_item(*item)), + element => element, + }; + + match current_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::GraphicList(mut sub_list) => { // A group's first child has no preceding sibling, so its clipping flag is inert until splicing - // hands it the group's own predecessor. Clear it (keeping the column) to stay clip-neutral. + // hands it the group's own predecessor. Clear it (keeping the attribute) to stay clip-neutral. if sub_list.attribute::(ATTR_CLIPPING_MASK, 0).is_some() { sub_list.set_attribute(ATTR_CLIPPING_MASK, 0, false); } @@ -235,6 +255,11 @@ pub fn is_paint_present(graphic_list: &List) -> bool { /// Bake the provided transform into the per-item transforms of the appearance's paint graphics. pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) { + fn bake_item_transform(item: &mut Item, transform: DAffine2) { + let baked = transform * item.attribute_cloned_or_default::(ATTR_TRANSFORM); + item.set_attribute(ATTR_TRANSFORM, baked); + } + fn bake_list_transform(list: &mut List, transform: DAffine2) { for item_transform in list.iter_attribute_values_mut_or_default::(ATTR_TRANSFORM) { *item_transform = transform * *item_transform; @@ -243,14 +268,20 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA fn bake_graphic_transform(graphic: &mut Graphic, transform: DAffine2) { match graphic { - Graphic::None => {} + Graphic::Graphic(item) => bake_item_transform(item, transform), + Graphic::Vector(item) => bake_item_transform(item, transform), + Graphic::RasterCPU(item) => bake_item_transform(item, transform), + Graphic::RasterGPU(item) => bake_item_transform(item, transform), + Graphic::Gradient(item) => bake_item_transform(item, transform), + Graphic::Text(item) => bake_item_transform(item, transform), Graphic::GraphicList(list) => bake_list_transform(list, transform), Graphic::VectorList(list) => bake_list_transform(list, transform), Graphic::RasterCPUList(list) => bake_list_transform(list, transform), Graphic::RasterGPUList(list) => bake_list_transform(list, transform), Graphic::GradientList(list) => bake_list_transform(list, transform), Graphic::TextList(list) => bake_list_transform(list, transform), - Graphic::ColorList(_) => {} + // A color has no spatial extent, so there is no placement for a transform to move + Graphic::None(_) | Graphic::NoneList(_) | Graphic::Color(_) | Graphic::ColorList(_) => {} } } @@ -271,31 +302,51 @@ pub trait TryFromGraphic: Clone + Sized { impl TryFromGraphic for Vector { fn try_from_graphic(graphic: Graphic) -> Option> { - if let Graphic::VectorList(t) = graphic { Some(t) } else { None } + match graphic { + Graphic::Vector(item) => Some(List::new_from_item(*item)), + Graphic::VectorList(list) => Some(list), + _ => None, + } } } impl TryFromGraphic for Raster { fn try_from_graphic(graphic: Graphic) -> Option> { - if let Graphic::RasterCPUList(t) = graphic { Some(t) } else { None } + match graphic { + Graphic::RasterCPU(item) => Some(List::new_from_item(*item)), + Graphic::RasterCPUList(list) => Some(list), + _ => None, + } } } impl TryFromGraphic for Color { fn try_from_graphic(graphic: Graphic) -> Option> { - if let Graphic::ColorList(t) = graphic { Some(t) } else { None } + match graphic { + Graphic::Color(item) => Some(List::new_from_item(item)), + Graphic::ColorList(list) => Some(list), + _ => None, + } } } impl TryFromGraphic for Gradient { fn try_from_graphic(graphic: Graphic) -> Option> { - if let Graphic::GradientList(t) = graphic { Some(t) } else { None } + match graphic { + Graphic::Gradient(item) => Some(List::new_from_item(item)), + Graphic::GradientList(list) => Some(list), + _ => None, + } } } impl TryFromGraphic for String { fn try_from_graphic(graphic: Graphic) -> Option> { - if let Graphic::TextList(t) = graphic { Some(t) } else { None } + match graphic { + Graphic::Text(item) => Some(List::new_from_item(item)), + Graphic::TextList(list) => Some(list), + _ => None, + } } } @@ -422,11 +473,23 @@ impl Graphic { pub fn had_clip_enabled(&self) -> bool { fn all_clipped(list: &List) -> bool { - list.iter_attribute_values_or_default::(ATTR_CLIPPING_MASK).all(|clip| clip) + !list.is_empty() && list.iter_attribute_values_or_default::(ATTR_CLIPPING_MASK).all(|clip| clip) + } + + fn item_clipped(item: &Item) -> bool { + item.attribute_cloned_or_default::(ATTR_CLIPPING_MASK) } match self { - Graphic::None => true, + Graphic::None(item) => item_clipped(item), + Graphic::Graphic(item) => item_clipped(item), + Graphic::Vector(item) => item_clipped(item), + Graphic::RasterCPU(item) => item_clipped(item), + Graphic::RasterGPU(item) => item_clipped(item), + Graphic::Color(item) => item_clipped(item), + Graphic::Gradient(item) => item_clipped(item), + Graphic::Text(item) => item_clipped(item), + Graphic::NoneList(list) => all_clipped(list), Graphic::VectorList(list) => all_clipped(list), Graphic::GraphicList(list) => all_clipped(list), Graphic::RasterCPUList(list) => all_clipped(list), @@ -439,109 +502,92 @@ impl Graphic { pub fn can_reduce_to_clip_path(&self) -> bool { match self { - Graphic::VectorList(vector) => (0..vector.len()).all(|index| { - let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.); - let appearance = vector.attribute::(ATTR_APPEARANCE, index); - - let fills_opaque_or_absent = appearance.is_none_or(|appearance| { - appearance - .covers_with_paints() - .filter(|(coverage, _)| coverage.cover() == Cover::Fill) - .all(|(_, paint)| paint.is_none_or(Graphic::is_opaque)) - }); - - let strokes_invisible_or_transparent = appearance.is_none_or(|appearance| { - appearance - .covers_with_paints() - .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) - .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(Graphic::is_fully_transparent)) - }); - - opacity > 1. - f64::EPSILON && fills_opaque_or_absent && strokes_invisible_or_transparent - }), + Graphic::Vector(item) => vector_can_reduce_to_clip_path(item.attribute_cloned_or(ATTR_OPACITY, 1.), item.attribute::(ATTR_APPEARANCE)), + Graphic::VectorList(list) => { + (0..list.len()).all(|index| vector_can_reduce_to_clip_path(list.attribute_cloned_or(ATTR_OPACITY, index, 1.), list.attribute::(ATTR_APPEARANCE, index))) + } _ => false, } } - pub fn is_opaque(&self) -> bool { + pub fn is_guaranteed_fully_opaque(&self) -> bool { match self { - Graphic::None => false, - Graphic::GraphicList(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque), + Graphic::None(_) | Graphic::NoneList(_) => false, + // The group's own opacity scales whatever it wraps, so full alpha there is a precondition + Graphic::Graphic(item) => item_opacity_is_full(item) && item.element().is_guaranteed_fully_opaque(), + Graphic::GraphicList(list) => !list.is_empty() && every_item_has_full_opacity(list) && list.iter_element_values().all(Graphic::is_guaranteed_fully_opaque), + Graphic::Vector(item) => vector_is_guaranteed_fully_opaque( + item.attribute_cloned_or(ATTR_OPACITY, 1.), + item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.), + item.attribute::(ATTR_APPEARANCE), + ), Graphic::VectorList(list) => { !list.is_empty() - && (0..list.len()).all(|i| { - 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 appearance = list.attribute::(ATTR_APPEARANCE, i); - - let fill_opaque = opacity_fill >= 1. - f64::EPSILON - && appearance.is_some_and(|appearance| { - appearance - .covers_with_paints() - .any(|(coverage, paint)| coverage.cover() == Cover::Fill && paint.is_some_and(Graphic::is_opaque)) - }); - - let strokes_opaque_or_invisible = appearance.is_none_or(|appearance| { - appearance - .covers_with_paints() - .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) - .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_some_and(Graphic::is_opaque)) - }); - - opacity >= 1. - f64::EPSILON && fill_opaque && strokes_opaque_or_invisible + && (0..list.len()).all(|index| { + vector_is_guaranteed_fully_opaque( + list.attribute_cloned_or(ATTR_OPACITY, index, 1.), + list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.), + list.attribute::(ATTR_APPEARANCE, index), + ) }) } + Graphic::RasterCPU(_) | Graphic::RasterCPUList(_) => false, + Graphic::RasterGPU(_) | Graphic::RasterGPUList(_) => false, + Graphic::Color(item) => item.element().is_opaque(), Graphic::ColorList(list) => list.element(0).is_some_and(|color| color.is_opaque()), - Graphic::GradientList(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())), - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) => false, + // A `Clear` spread cuts off to transparency past the ends, leaving the rest of the region unpainted + Graphic::Gradient(item) => item.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD) != GradientSpread::Clear && item.element().iter().all(|stop| stop.color.is_opaque()), + Graphic::GradientList(list) => { + list.attribute_cloned_or_default::(ATTR_GRADIENT_SPREAD, 0) != GradientSpread::Clear + && list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())) + } + Graphic::Text(_) | Graphic::TextList(_) => false, } } - pub fn is_fully_transparent(&self) -> bool { + pub fn is_guaranteed_fully_transparent(&self) -> bool { match self { - Graphic::None => true, - Graphic::GraphicList(list) => list.iter_element_values().all(Graphic::is_fully_transparent), - Graphic::VectorList(list) => (0..list.len()).all(|i| { - 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 appearance = list.attribute::(ATTR_APPEARANCE, i); - - let fills_invisible = opacity_fill <= f64::EPSILON - || appearance.is_none_or(|appearance| { - appearance - .covers_with_paints() - .filter(|(coverage, _)| coverage.cover() == Cover::Fill) - .all(|(_, paint)| paint.is_none_or(Graphic::is_fully_transparent)) - }); - - let strokes_invisible = appearance.is_none_or(|appearance| { - appearance - .covers_with_paints() - .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) - .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(Graphic::is_fully_transparent)) - }); - - fills_invisible && strokes_invisible + Graphic::None(_) | Graphic::NoneList(_) => true, + Graphic::Graphic(item) => item_opacity_is_zero(item) || item.element().is_guaranteed_fully_transparent(), + Graphic::GraphicList(list) => every_item_has_zero_opacity(list) || list.iter_element_values().all(Graphic::is_guaranteed_fully_transparent), + Graphic::Vector(item) => vector_is_guaranteed_fully_transparent( + item.attribute_cloned_or(ATTR_OPACITY, 1.), + item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.), + item.attribute::(ATTR_APPEARANCE), + ), + Graphic::VectorList(list) => (0..list.len()).all(|index| { + vector_is_guaranteed_fully_transparent( + list.attribute_cloned_or(ATTR_OPACITY, index, 1.), + list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.), + list.attribute::(ATTR_APPEARANCE, index), + ) }), + Graphic::Color(item) => item.element().a() == 0., Graphic::ColorList(list) => list.iter_element_values().all(|color| color.a() == 0.), - Graphic::GradientList(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), - Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::TextList(_) => false, + // A stopless ramp paints as solid black, matching `Gradient::evaluate`, so it counts as transparent only once it has stops + Graphic::Gradient(item) => !item.element().is_empty() && item.element().iter().all(|stop| stop.color.a() == 0.), + Graphic::GradientList(list) => list.iter_element_values().all(|stops| !stops.is_empty() && stops.iter().all(|stop| stop.color.a() == 0.)), + // Their content is never inspected, so zeroed opacity is the only invisibility these can report + Graphic::RasterCPU(item) => item_opacity_is_zero(item), + Graphic::RasterGPU(item) => item_opacity_is_zero(item), + Graphic::Text(item) => item_opacity_is_zero(item), + Graphic::RasterCPUList(list) => every_item_has_zero_opacity(list), + Graphic::RasterGPUList(list) => every_item_has_zero_opacity(list), + Graphic::TextList(list) => every_item_has_zero_opacity(list), } } - /// 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::ColorList(_) | Graphic::GradientList(_)) && self.is_opaque() + /// True if this paint fully, opaquely covers the entire fill region. + pub fn is_guaranteed_to_cover_opaquely(&self) -> bool { + matches!(self, Graphic::Color(_) | Graphic::Gradient(_) | Graphic::ColorList(_) | Graphic::GradientList(_)) && self.is_guaranteed_fully_opaque() } /// Returns true if this graphic contains no content. pub fn is_empty(&self) -> bool { match self { - Graphic::None => true, + // A leaf always holds exactly one element, so only the none-typed content is truly empty + Graphic::None(_) | Graphic::NoneList(_) => true, + Graphic::Graphic(_) | Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) | Graphic::Text(_) => false, Graphic::GraphicList(list) => list.is_empty(), Graphic::VectorList(list) => list.is_empty(), Graphic::ColorList(list) => list.is_empty(), @@ -553,29 +599,125 @@ impl Graphic { } } -/// Combined bounding box of a vector list's rows, inflating each row by its appearance's stroke when `include_stroke`. -/// Stroke parameters live on the row attribute, out of reach of the element-level impl. +/// Whether a vector object's own opacity and paint let a clipper reduce to an SVG `` instead of a ``. +fn vector_can_reduce_to_clip_path(opacity: f64, appearance: Option<&Appearance>) -> bool { + let fills_opaque_or_absent = appearance.is_none_or(|appearance| { + appearance + .covers_with_paints() + .filter(|(coverage, _)| coverage.cover() == Cover::Fill) + .all(|(_, paint)| paint.is_none_or(Graphic::is_guaranteed_fully_opaque)) + }); + + let strokes_invisible_or_transparent = appearance.is_none_or(|appearance| { + appearance + .covers_with_paints() + .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) + .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(Graphic::is_guaranteed_fully_transparent)) + }); + + opacity > 1. - f64::EPSILON && fills_opaque_or_absent && strokes_invisible_or_transparent +} + +/// Whether a vector object paints its whole interior at full alpha, so nothing behind it can show through. +fn vector_is_guaranteed_fully_opaque(opacity: f64, opacity_fill: f64, appearance: Option<&Appearance>) -> bool { + let fill_opaque = opacity_fill >= 1. - f64::EPSILON + && appearance.is_some_and(|appearance| { + appearance + .covers_with_paints() + .any(|(coverage, paint)| coverage.cover() == Cover::Fill && paint.is_some_and(Graphic::is_guaranteed_fully_opaque)) + }); + + let strokes_opaque_or_invisible = appearance.is_none_or(|appearance| { + appearance + .covers_with_paints() + .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) + .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_some_and(Graphic::is_guaranteed_fully_opaque)) + }); + + opacity >= 1. - f64::EPSILON && fill_opaque && strokes_opaque_or_invisible +} + +/// Whether a vector object draws nothing visible, either through its opacity or through its paint. +fn vector_is_guaranteed_fully_transparent(opacity: f64, opacity_fill: f64, appearance: Option<&Appearance>) -> bool { + if opacity <= f64::EPSILON { + return true; + } + + let fills_invisible = opacity_fill <= f64::EPSILON + || appearance.is_none_or(|appearance| { + appearance + .covers_with_paints() + .filter(|(coverage, _)| coverage.cover() == Cover::Fill) + .all(|(_, paint)| paint.is_none_or(Graphic::is_guaranteed_fully_transparent)) + }); + + let strokes_invisible = appearance.is_none_or(|appearance| { + appearance + .covers_with_paints() + .filter(|(coverage, _)| coverage.cover() == Cover::Stroke) + .all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(Graphic::is_guaranteed_fully_transparent)) + }); + + fills_invisible && strokes_invisible +} + +/// Whether a lone item's opacity zeroes it out, independent of what its element holds. +fn item_opacity_is_zero(item: &Item) -> bool { + item.attribute_cloned_or::(ATTR_OPACITY, 1.) <= f64::EPSILON +} + +/// Whether every item of a list is zeroed out by its opacity, which an empty list satisfies with nothing to draw. +fn every_item_has_zero_opacity(list: &List) -> bool { + (0..list.len()).all(|index| list.attribute_cloned_or::(ATTR_OPACITY, index, 1.) <= f64::EPSILON) +} + +/// Whether a lone item passes its content through at full opacity, covering both factors the renderer multiplies together. +fn item_opacity_is_full(item: &Item) -> bool { + item.attribute_cloned_or::(ATTR_OPACITY, 1.) >= 1. - f64::EPSILON && item.attribute_cloned_or::(ATTR_OPACITY_FILL, 1.) >= 1. - f64::EPSILON +} + +/// Whether every item of a list passes its content through with full opacity. +fn every_item_has_full_opacity(list: &List) -> bool { + (0..list.len()).all(|index| list.attribute_cloned_or::(ATTR_OPACITY, index, 1.) >= 1. - f64::EPSILON && list.attribute_cloned_or::(ATTR_OPACITY_FILL, index, 1.) >= 1. - f64::EPSILON) +} + +/// Bounding box of one vector, inflated by its appearance's stroke when `include_stroke` is true. +/// Stroke parameters live on the item attribute, out of reach of the element-level impl. +fn vector_bounding_box(vector: &Vector, composed_transform: DAffine2, appearance: Option<&Appearance>, include_stroke: bool) -> Option<[DVec2; 2]> { + let mut bounds = vector.bounding_box_with_transform(composed_transform)?; + + // The full line width (not half) accounts for different styles of stroke caps + if include_stroke && let Some(stroke) = appearance.and_then(|appearance| appearance.first_coverage_of(Cover::Stroke)).map(Coverage::stroke_params) { + let scale = composed_transform.scale_magnitudes(); + let offset = DVec2::splat(stroke.weight() * scale.x.max(scale.y) * stroke.join_miter_limit); + bounds = [bounds[0] - offset, bounds[1] + offset]; + } + + Some(bounds) +} + +/// Bounding box of a lone vector, inflating it by its appearance's stroke when `include_stroke`. +pub fn vector_item_bounding_box(item: &Item, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { + let composed_transform = transform * item.attribute_cloned_or_default::(ATTR_TRANSFORM); + + match vector_bounding_box(item.element(), composed_transform, item.attribute::(ATTR_APPEARANCE), include_stroke) { + Some(bounds) => RenderBoundingBox::Rectangle(bounds), + None => RenderBoundingBox::None, + } +} + +/// Combined bounding box of a vector list's items, inflating each item by its appearance's stroke when `include_stroke`. pub fn vector_list_bounding_box(list: &List, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { let mut combined_bounds: Option<[DVec2; 2]> = None; for index in 0..list.len() { let Some(element) = list.element(index) else { continue }; let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let row_transform = transform * item_transform; + let appearance = list.attribute::(ATTR_APPEARANCE, index); - let Some(mut bounds) = element.bounding_box_with_transform(row_transform) else { continue }; - - // The full line width (not half) accounts for different styles of stroke caps - if include_stroke - && let Some(stroke) = list - .attribute::(ATTR_APPEARANCE, index) - .and_then(|appearance| appearance.first_coverage_of(Cover::Stroke)) - .map(Coverage::stroke_params) - { - let scale = row_transform.scale_magnitudes(); - let offset = DVec2::splat(stroke.weight() * scale.x.max(scale.y) * stroke.join_miter_limit); - bounds = [bounds[0] - offset, bounds[1] + offset]; - } + let Some(bounds) = vector_bounding_box(element, transform * item_transform, appearance, include_stroke) else { + continue; + }; combined_bounds = Some(match combined_bounds { Some(existing) => Quad::combine_bounds(existing, bounds), @@ -592,7 +734,14 @@ pub fn vector_list_bounding_box(list: &List, transform: DAffine2, includ impl BoundingBox for Graphic { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { match self { - Graphic::None => RenderBoundingBox::None, + Graphic::None(_) | Graphic::NoneList(_) => RenderBoundingBox::None, + Graphic::Graphic(item) => item.bounding_box(transform, include_stroke), + Graphic::Vector(item) => vector_item_bounding_box(item, transform, include_stroke), + Graphic::RasterCPU(item) => item.bounding_box(transform, include_stroke), + Graphic::RasterGPU(item) => item.bounding_box(transform, include_stroke), + Graphic::Color(item) => item.bounding_box(transform, include_stroke), + Graphic::Gradient(item) => item.bounding_box(transform, include_stroke), + Graphic::Text(item) => item.bounding_box(transform, include_stroke), Graphic::VectorList(list) => vector_list_bounding_box(list, transform, include_stroke), Graphic::RasterCPUList(list) => list.bounding_box(transform, include_stroke), Graphic::RasterGPUList(list) => list.bounding_box(transform, include_stroke), @@ -605,7 +754,14 @@ impl BoundingBox for Graphic { fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { match self { - Graphic::None => RenderBoundingBox::None, + Graphic::None(_) | Graphic::NoneList(_) => RenderBoundingBox::None, + Graphic::Graphic(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::Vector(item) => vector_item_bounding_box(item, transform, include_stroke), + Graphic::RasterCPU(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::RasterGPU(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::Color(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::Gradient(item) => item.thumbnail_bounding_box(transform, include_stroke), + Graphic::Text(item) => item.thumbnail_bounding_box(transform, include_stroke), Graphic::VectorList(vector) => vector_list_bounding_box(vector, transform, include_stroke), Graphic::RasterCPUList(raster) => raster.thumbnail_bounding_box(transform, include_stroke), Graphic::RasterGPUList(raster) => raster.thumbnail_bounding_box(transform, include_stroke), @@ -620,7 +776,14 @@ impl BoundingBox for Graphic { impl RenderComplexity for Graphic { fn render_complexity(&self) -> usize { match self { - Self::None => 0, + Self::None(_) | Self::NoneList(_) => 0, + Self::Graphic(item) => item.render_complexity(), + Self::Vector(item) => item.render_complexity(), + Self::RasterCPU(item) => item.render_complexity(), + Self::RasterGPU(item) => item.render_complexity(), + Self::Color(item) => item.render_complexity(), + Self::Gradient(item) => item.render_complexity(), + Self::Text(item) => item.render_complexity(), Self::GraphicList(list) => list.render_complexity(), Self::VectorList(list) => list.render_complexity(), Self::RasterCPUList(list) => list.render_complexity(), @@ -753,7 +916,7 @@ mod tests { assert_eq!(layers, [Some(NodeId(7)), Some(NodeId(9))]); } - // Flattening must not invent attribute columns that neither the parent graphic nor the child carried + // Flattening must not invent attributes that neither the parent graphic nor the child carried #[test] fn flatten_does_not_invent_attributes() { let graphics = List::new_from_element(vector_graphic()); @@ -779,12 +942,12 @@ mod tests { // A padded (empty) appearance cell is undeclared, so the parent's appearance cascades into it while a declared sibling keeps its own #[test] - fn flatten_cascades_into_padded_empty_appearance_rows() { + fn flatten_cascades_into_padded_empty_appearance_items() { use core_types::Color; let solid = |color: Color| Graphic::ColorList(List::new_from_element(color)); - // Declaring an appearance on row 0 forces the column, padding row 1 with the empty appearance + // Declaring an appearance on item 0 forces the attribute, padding item 1 with the empty appearance let mut inner = List::new(); inner.push(Item::new_from_element(Vector::default())); inner.push(Item::new_from_element(Vector::default())); @@ -800,8 +963,8 @@ mod tests { colors.element(0).copied() }; - assert_eq!(color_of(0), Some(Color::BLACK), "a declared row should keep its own appearance"); - assert_eq!(color_of(1), Some(Color::WHITE), "a padded row should inherit the parent appearance"); + assert_eq!(color_of(0), Some(Color::BLACK), "a declared item should keep its own appearance"); + assert_eq!(color_of(1), Some(Color::WHITE), "a padded item should inherit the parent appearance"); } } @@ -826,19 +989,19 @@ mod graphic_is_opaque_tests { #[test] fn opaque_color_is_opaque() { let g = color_graphic(1.); - assert!(g.is_opaque()); + assert!(g.is_guaranteed_fully_opaque()); } #[test] fn transparent_color_is_not_opaque() { let g = color_graphic(0.5); - assert!(!g.is_opaque()); + assert!(!g.is_guaranteed_fully_opaque()); } #[test] fn vector_is_not_opaque() { let g = Graphic::VectorList(List::default()); - assert!(!g.is_opaque()); + assert!(!g.is_guaranteed_fully_opaque()); } #[test] @@ -858,7 +1021,7 @@ mod graphic_is_opaque_tests { }, ]); let g = gradient_graphic(gradient); - assert!(g.is_opaque()); + assert!(g.is_guaranteed_fully_opaque()); } #[test] @@ -878,6 +1041,52 @@ mod graphic_is_opaque_tests { }, ]); let g = gradient_graphic(gradient); - assert!(!g.is_opaque()); + assert!(!g.is_guaranteed_fully_opaque()); + } + + #[test] + fn gradient_with_clear_spread_is_not_opaque() { + let opaque = Color::from_rgbaf32(1., 0., 0., 1.).unwrap(); + let gradient = Gradient::new(vec![ + GradientStop { + position: 0., + midpoint: 0.5, + color: opaque, + }, + GradientStop { + position: 1., + midpoint: 0.5, + color: opaque, + }, + ]); + + let mut gradient_list = List::new_from_element(gradient); + gradient_list.set_attribute(ATTR_GRADIENT_SPREAD, 0, GradientSpread::Clear); + + assert!( + !Graphic::GradientList(gradient_list).is_guaranteed_fully_opaque(), + "a clear spread leaves the region past the ends unpainted" + ); + } + + #[test] + fn partial_group_opacity_is_not_opaque() { + let mut list = List::new_from_element(color_graphic(1.)); + assert!(Graphic::GraphicList(list.clone()).is_guaranteed_fully_opaque()); + + list.set_attribute(ATTR_OPACITY, 0, 0.5); + assert!(!Graphic::GraphicList(list.clone()).is_guaranteed_fully_opaque()); + + list.set_attribute(ATTR_OPACITY, 0, 0.); + assert!(Graphic::GraphicList(list).is_guaranteed_fully_transparent()); + } + + #[test] + fn partial_leaf_group_opacity_is_not_opaque() { + let item = Item::new_from_element(color_graphic(1.)); + assert!(Graphic::Graphic(Box::new(item.clone())).is_guaranteed_fully_opaque()); + + let reduced = item.with_attribute(ATTR_OPACITY, 0.5); + assert!(!Graphic::Graphic(Box::new(reduced)).is_guaranteed_fully_opaque()); } } diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index 3ade761151..6feee87264 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -1,4 +1,4 @@ -use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible}; +use crate::renderer::{ClearGuardPlacement, ItemRef, RenderParams, format_transform_matrix, gradient_placement, gradient_settings_from_item, spread_adjusted_samples, transform_is_invertible}; use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; @@ -10,7 +10,7 @@ use graphic_types::vector_types::gradient::GradientForm; use graphic_types::vector_types::vector::style::{Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::Gradient; -use vector_types::gradient::{GradientSettings, GradientSpread}; +use vector_types::gradient::GradientSpread; #[derive(Copy, Clone, PartialEq)] pub enum PaintTarget { @@ -50,6 +50,20 @@ pub trait RenderExt { ) -> Self::Output; } +/// The paint attribute for a solid color, or `none` when the color is absent. +fn render_color_paint(color: Option<&Color>, target: PaintTarget) -> String { + let Some(color) = color else { + return format!(r#" {}="none""#, target.paint_attr()); + }; + + 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 List { type Output = String; @@ -63,23 +77,91 @@ impl RenderExt for List { _render_params: &RenderParams, target: PaintTarget, ) -> Self::Output { - let Some(color) = self.element(0) else { - return format!(r#" {}="none""#, target.paint_attr()); - }; - - 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 + render_color_paint(self.element(0), target) } } -impl RenderExt for List { - type Output = u64; +/// Adds one gradient item's def into `svg_defs` and returns the gradient ID, or `None` when the item is absent. +fn render_gradient_paint(item: Option>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> Option { + let mut stop = String::new(); - /// Adds the gradient def through mutating the first argument, returning the gradient ID. + let item = item?; + let stops = item.element()?; + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let local_gradient_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let settings = gradient_settings_from_item(item); + + let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); + + for (position, color, original_midpoint) in samples { + stop.push_str("") + } + + // A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec) + if stop.is_empty() { + stop.push_str(r##""##); + } + + // Need to cancel out the element's transform as it is already applied to the path itself. + let element_transform_inverse = if transform_is_invertible(element_transform) { + element_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + let document_transform = item_transform * local_gradient_transform; + + let placement = gradient_placement(document_transform, gradient_form); + let gradient_transform = format_transform_matrix(element_transform_inverse * placement); + let gradient_transform = if gradient_transform.is_empty() { + String::new() + } else { + format!(r#" gradientTransform="{gradient_transform}""#) + }; + + let gradient_spread = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { + String::new() + } else { + format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) + }; + + let gradient_id = generate_uuid(); + + match gradient_form { + GradientForm::Linear => { + let _ = write!( + svg_defs, + r#"{}"#, + gradient_id, stop + ); + } + GradientForm::Radial => { + let _ = write!( + svg_defs, + r#"{}"#, + gradient_id, stop + ); + } + } + + Some(gradient_id) +} + +impl RenderExt for List { + type Output = Option; + + /// Adds the gradient def through mutating the first argument, returning the gradient ID, or `None` when the list is empty. fn render( &self, svg_defs: &mut String, @@ -90,78 +172,7 @@ impl RenderExt for List { _render_params: &RenderParams, _target: PaintTarget, ) -> Self::Output { - let mut stop = String::new(); - - let Some(stops) = self.element(0) else { return 0 }; - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0); - let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let settings = GradientSettings::from_list_row_attributes(self, 0); - - let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); - - for (position, color, original_midpoint) in samples { - stop.push_str("") - } - - // A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec) - if stop.is_empty() { - stop.push_str(r##""##); - } - - // Need to cancel out the element's transform as it is already applied to the path itself. - let element_transform_inverse = if transform_is_invertible(element_transform) { - element_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let document_transform = item_transform * local_gradient_transform; - - let placement = gradient_placement(document_transform, gradient_form); - let gradient_transform = format_transform_matrix(element_transform_inverse * placement); - let gradient_transform = if gradient_transform.is_empty() { - String::new() - } else { - format!(r#" gradientTransform="{gradient_transform}""#) - }; - - let gradient_spread = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { - String::new() - } else { - format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) - }; - - let gradient_id = generate_uuid(); - - match gradient_form { - GradientForm::Linear => { - let _ = write!( - svg_defs, - r#"{}"#, - gradient_id, stop - ); - } - GradientForm::Radial => { - let _ = write!( - svg_defs, - r#"{}"#, - gradient_id, stop - ); - } - } - - gradient_id + render_gradient_paint((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), svg_defs, item_transform, element_transform) } } @@ -242,13 +253,26 @@ impl RenderExt for List { let paint_attr = target.paint_attr(); match fill_graphic { + Some(Graphic::Color(item)) => render_color_paint(Some(item.element()), target), Some(Graphic::ColorList(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target), - Some(Graphic::GradientList(gradient_list)) => { - let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target); - format!(r##" {paint_attr}="url(#{gradient_id})""##) - } - Some(Graphic::None) => format!(r#" {paint_attr}="none""#), - Some(Graphic::VectorList(_)) | Some(Graphic::RasterCPUList(_)) | Some(Graphic::RasterGPUList(_)) | Some(Graphic::GraphicList(_)) | Some(Graphic::TextList(_)) => { + Some(Graphic::Gradient(item)) => render_gradient_paint(Some(ItemRef::Item(item)), svg_defs, item_transform, element_transform) + .map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##)) + .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)), + Some(Graphic::GradientList(gradient_list)) => gradient_list + .render(svg_defs, item_transform, element_transform, stroke_transform, bounds, render_params, target) + .map(|gradient_id| format!(r##" {paint_attr}="url(#{gradient_id})""##)) + .unwrap_or_else(|| format!(r#" {paint_attr}="none""#)), + Some(Graphic::None(_)) | Some(Graphic::NoneList(_)) => format!(r#" {paint_attr}="none""#), + Some(Graphic::Graphic(_)) + | Some(Graphic::Vector(_)) + | Some(Graphic::RasterCPU(_)) + | Some(Graphic::RasterGPU(_)) + | Some(Graphic::Text(_)) + | Some(Graphic::VectorList(_)) + | Some(Graphic::RasterCPUList(_)) + | Some(Graphic::RasterGPUList(_)) + | Some(Graphic::GraphicList(_)) + | Some(Graphic::TextList(_)) => { 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. }; diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index cecc2dfec9..b21cd78699 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -42,6 +42,62 @@ use std::sync::{Arc, LazyLock}; use vector_types::gradient::{GradientSettings, GradientSpread}; use vello::*; +/// A borrowed view of one item of ranked content: one index of a `List`'s attributes, or a lone `Item` reading its own envelope. +/// Lets the per-item render logic serve both the list impls and the `Graphic` leaf variants without cloning. +pub(crate) enum ItemRef<'a, T> { + ListItem(&'a List, usize), + Item(&'a Item), +} + +impl Copy for ItemRef<'_, T> {} +impl Clone for ItemRef<'_, T> { + fn clone(&self) -> Self { + *self + } +} + +impl<'a, T> ItemRef<'a, T> { + pub(crate) fn element(self) -> Option<&'a T> { + match self { + ItemRef::ListItem(list, index) => list.element(index), + ItemRef::Item(item) => Some(item.element()), + } + } + + pub(crate) fn attribute(self, key: &str) -> Option<&'a A> { + match self { + ItemRef::ListItem(list, index) => list.attribute(key, index), + ItemRef::Item(item) => item.attribute(key), + } + } + + pub(crate) fn attribute_cloned_or(self, key: &str, fallback: A) -> A { + match self { + ItemRef::ListItem(list, index) => list.attribute_cloned_or(key, index, fallback), + ItemRef::Item(item) => item.attribute_cloned_or(key, fallback), + } + } + + pub(crate) fn attribute_cloned_or_default(self, key: &str) -> A { + match self { + ItemRef::ListItem(list, index) => list.attribute_cloned_or_default(key, index), + ItemRef::Item(item) => item.attribute_cloned_or_default(key), + } + } + + pub(crate) fn clone_item_attributes(self) -> core_types::list::ItemAttributeValues { + match self { + ItemRef::ListItem(list, index) => list.clone_item_attributes(index), + ItemRef::Item(item) => item.attributes().clone(), + } + } + + /// The last layer ID of the item's `editor:layer_path` tag, if any. + fn layer(self) -> Option { + self.attribute::(ATTR_EDITOR_LAYER_PATH).and_then(|path| path.0.iter_element_values().next_back().copied()) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] enum MaskType { @@ -387,6 +443,14 @@ fn emit_svg_fill_path( }); } +/// The whole-ramp settings a gradient item carries beside its element, defaulting each absent one. +pub(crate) fn gradient_settings_from_item(item: ItemRef<'_, Gradient>) -> GradientSettings { + match item { + ItemRef::ListItem(list, index) => GradientSettings::from_list_row_attributes(list, index), + ItemRef::Item(item) => GradientSettings::from_item_attributes(item), + } +} + /// Whether the affine transform inverts to a finite matrix (a zero, subnormal, or NaN determinant does not). pub(crate) fn transform_is_invertible(transform: DAffine2) -> bool { transform.matrix2.determinant().recip().is_finite() @@ -513,12 +577,12 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend { } } -fn create_peniko_gradient_brush(gradient_list: &List, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { - let stops = gradient_list.element(0)?; +fn create_peniko_gradient_brush(gradient_item: ItemRef<'_, Gradient>, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> { + let stops = gradient_item.element()?; - let gradient_form: GradientForm = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_FORM, 0); - let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let settings = GradientSettings::from_list_row_attributes(gradient_list, 0); + let gradient_form: GradientForm = gradient_item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let gradient_transform: DAffine2 = gradient_item.attribute_cloned_or_default(ATTR_TRANSFORM); + let settings = gradient_settings_from_item(gradient_item); let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); @@ -564,12 +628,12 @@ pub struct RenderMetadata { /// Source-geometry outlines for hover/selection overlays, separate from `click_targets` so /// nodes with an `editor:click_target` override still outline the precise geometry. pub outlines: HashMap>>, - /// Per-layer text frame from row 0's `editor:text_frame` attribute. + /// Per-layer text frame from item 0's `editor:text_frame` attribute. /// The Text tool composes this with `transform_to_viewport(layer)` to position its drag cage. pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_data: HashMap>, - /// Per-layer `ATTR_APPEARANCE` row attribute, exposed so message handlers can read it. + /// Per-layer `ATTR_APPEARANCE` item attribute, exposed so message handlers can read it. #[cfg_attr(feature = "serde", serde(skip))] pub appearance_attributes: HashMap>, pub backgrounds: Vec, @@ -651,10 +715,270 @@ pub trait Render: BoundingBox + RenderComplexity { fn new_ids_from_hash(&mut self, _reference: Option) {} } +/// Emits one item of graphic content as SVG, wrapped in a group carrying the item's transform, opacity, and blend mode. +/// `mask_state` carries the sibling clipping run between a list's items; a lone item has no siblings, so both mask inputs stay inert. +fn render_graphic_item_svg(item: ItemRef<'_, Graphic>, next_clips: bool, mask_state: &mut Option<(u64, MaskType)>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(element) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + // This item's declared appearance (if any) cascades to descendants lacking their own + let child_render_params = item + .attribute::(ATTR_APPEARANCE) + .and_then(Appearance::declared) + .map(|appearance| render_params.for_child_item(appearance)); + let render_params = child_render_params.as_ref().unwrap_or(render_params); + + let matrix = format_transform_matrix(transform); + let mut masked_by = None; + + if next_clips && mask_state.is_none() { + let uuid = generate_uuid(); + let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; + + let mut svg = SvgRender::new(); + element.render_svg(&mut svg, &render_params.for_clipper()); + + // The def is resolved in this list's space, so the masker's own transform has to be baked into it + let masker = match matrix.is_empty() { + true => svg.svg.to_svg_string(), + false => format!(r##"{}"##, svg.svg.to_svg_string()), + }; + + render.svg_defs.push_str(&svg.svg_defs); + mask_type.write_to_defs(&mut render.svg_defs, uuid, masker); + + *mask_state = Some((uuid, mask_type)); + } else if let Some((uuid, mask_type)) = *mask_state { + if !next_clips { + *mask_state = None; + } + + masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); + } + + let render_item = |render: &mut SvgRender| { + render.parent_tag( + "g", + |attributes| { + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix.clone()); + } + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }, + |render| element.render_svg(render, render_params), + ); + }; + + // The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own + match masked_by { + Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_item), + None => render_item(render), + } +} + +/// Draws one item of graphic content into the Vello scene, layering for the item's opacity, blend mode, and sibling clipping. +/// `mask_element_and_transform` carries the clipping run between a list's items; a lone item passes inert mask inputs. +#[allow(clippy::too_many_arguments)] +fn render_graphic_item_to_vello<'a>( + item: ItemRef<'a, Graphic>, + next_clips: bool, + mask_element_and_transform: &mut Option<(&'a Graphic, DAffine2)>, + scene: &mut Scene, + transform: DAffine2, + context: &mut RenderContext, + render_params: &RenderParams, +) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let transform = transform * item_transform; + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + // This item's declared appearance (if any) cascades to descendants lacking their own + let child_render_params = item + .attribute::(ATTR_APPEARANCE) + .and_then(Appearance::declared) + .map(|appearance| render_params.for_child_item(appearance)); + let render_params = child_render_params.as_ref().unwrap_or(render_params); + + let mut layer = false; + + let blend_mode = match render_params.render_mode { + RenderMode::Outline => peniko::Mix::Normal, + _ => blend_mode_attr.to_peniko(), + }; + let mut bounds = RenderBoundingBox::None; + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) { + bounds = element.bounding_box(transform, true); + + if let RenderBoundingBox::Rectangle(bounds) = bounds { + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), + opacity, + kurbo::Affine::IDENTITY, + &kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), + ); + layer = true; + } + } + + if next_clips && mask_element_and_transform.is_none() { + *mask_element_and_transform = Some((element, transform)); + + element.render_to_vello(scene, transform, context, render_params); + } else if let Some((mask_element, transform_mask)) = *mask_element_and_transform { + if !next_clips { + *mask_element_and_transform = None; + } + if !layer { + bounds = element.bounding_box(transform, true); + } + + if let RenderBoundingBox::Rectangle(bounds) = bounds { + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); + mask_element.render_to_vello(scene, transform_mask, context, &render_params.for_clipper()); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + kurbo::Affine::IDENTITY, + &rect, + ); + } + + element.render_to_vello(scene, transform, context, render_params); + + if matches!(bounds, RenderBoundingBox::Rectangle(_)) { + scene.pop_layer(); + scene.pop_layer(); + } + } else { + element.render_to_vello(scene, transform, context, render_params); + } + + if layer { + scene.pop_layer(); + } +} + +/// Recurses one item of graphic content for metadata, composing the item's transform into the footprint and cascading its appearance. +fn collect_graphic_item_metadata(item: ItemRef<'_, Graphic>, metadata: &mut RenderMetadata, footprint: Footprint, inherited_appearance: Option<&Appearance>) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + // This item's appearance (if any) cascades to descendants lacking their own + let child_appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + + let mut footprint = footprint; + footprint.transform *= item_transform; + + // An anonymous wrapper item (no layer tag) still recurses to reach nested content with "editor:layer_path" attributes + element.collect_metadata(metadata, footprint, item.layer(), child_appearance); +} + +/// Collects one graphic item's click and outline targets, baked through the item's transform. +fn collect_graphic_item_targets(item: ItemRef<'_, Graphic>, inherited_appearance: Option<&Appearance>, click_targets: &mut Vec, outlines: &mut Vec) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let child_appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + + let mut new_click_targets = Vec::new(); + element.add_upstream_click_targets(&mut new_click_targets, child_appearance); + for click_target in new_click_targets.iter_mut() { + click_target.apply_transform(item_transform) + } + click_targets.extend(new_click_targets); + + let mut new_outlines = Vec::new(); + element.add_upstream_outline_targets(&mut new_outlines, child_appearance); + for outline in new_outlines.iter_mut() { + outline.apply_transform(item_transform) + } + outlines.extend(new_outlines); +} + +/// The full metadata pass over a run of graphic items: per-item recursion, then the aggregated targets when an `element_id` names the run. +fn collect_graphic_items_metadata<'a>( + items: impl Iterator> + Clone, + metadata: &mut RenderMetadata, + footprint: Footprint, + element_id: Option, + inherited_appearance: Option<&Appearance>, +) { + for item in items.clone() { + collect_graphic_item_metadata(item, metadata, footprint, inherited_appearance); + } + + if let Some(element_id) = element_id { + let mut all_upstream_click_targets = Vec::new(); + let mut all_upstream_outlines = Vec::new(); + + for item in items { + collect_graphic_item_targets(item, inherited_appearance, &mut all_upstream_click_targets, &mut all_upstream_outlines); + } + + metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); + metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect()); + } +} + +/// Collects one graphic item's click targets into the caller's list, baked through the item's transform. +fn add_graphic_item_click_targets(item: ItemRef<'_, Graphic>, click_targets: &mut Vec, inherited_appearance: Option<&Appearance>) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let child_appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + let mut new_click_targets = Vec::new(); + + element.add_upstream_click_targets(&mut new_click_targets, child_appearance); + + for click_target in new_click_targets.iter_mut() { + click_target.apply_transform(item_transform) + } + + click_targets.extend(new_click_targets); +} + +/// Collects one graphic item's outline targets into the caller's list, baked through the item's transform. +fn add_graphic_item_outline_targets(item: ItemRef<'_, Graphic>, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let child_appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + let mut new_outlines = Vec::new(); + + element.add_upstream_outline_targets(&mut new_outlines, child_appearance); + + for outline in new_outlines.iter_mut() { + outline.apply_transform(item_transform) + } + + outlines.extend(new_outlines); +} + impl Render for Graphic { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { match self { - Graphic::None => (), + Graphic::None(_) | Graphic::NoneList(_) => (), + Graphic::Graphic(item) => render_graphic_item_svg(ItemRef::Item(item), false, &mut None, render, render_params), + Graphic::Vector(item) => render_vector_item_svg(ItemRef::Item(item), false, &mut None, render, render_params), + Graphic::RasterCPU(item) => render_raster_cpu_item_svg(ItemRef::Item(item), render, render_params), + Graphic::RasterGPU(_) => (), + Graphic::Color(item) => render_color_item_svg(ItemRef::Item(item), render, render_params), + Graphic::Gradient(item) => render_gradient_item_svg(ItemRef::Item(item), render, render_params), + Graphic::Text(item) => render_text_item_svg(ItemRef::Item(item), render, render_params), Graphic::GraphicList(list) => list.render_svg(render, render_params), Graphic::VectorList(list) => list.render_svg(render, render_params), Graphic::RasterCPUList(list) => list.render_svg(render, render_params), @@ -667,7 +991,21 @@ impl Render for Graphic { fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { match self { - Graphic::None => (), + Graphic::None(_) | Graphic::NoneList(_) => (), + Graphic::Graphic(item) => render_graphic_item_to_vello(ItemRef::Item(item), false, &mut None, scene, transform, context, render_params), + Graphic::Vector(item) => { + // A paint subtree supplies its own styling, so an element's appearance must not cascade into it + let paint_render_params = RenderParams { + inherited_appearance: None, + ..render_params.clone() + }; + render_vector_item_to_vello(ItemRef::Item(item), false, &mut None, scene, transform, context, render_params, &paint_render_params); + } + Graphic::RasterCPU(item) => render_raster_cpu_item_to_vello(ItemRef::Item(item), scene, transform, render_params), + Graphic::RasterGPU(item) => render_raster_gpu_item_to_vello(ItemRef::Item(item), scene, transform, context, render_params), + Graphic::Color(item) => render_color_item_to_vello(ItemRef::Item(item), scene, render_params), + Graphic::Gradient(item) => render_gradient_item_to_vello(ItemRef::Item(item), scene, transform, render_params), + Graphic::Text(item) => render_text_item_to_vello(ItemRef::Item(item), scene, transform, render_params), Graphic::GraphicList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::VectorList(list) => list.render_to_vello(scene, transform, context, render_params), Graphic::RasterCPUList(list) => list.render_to_vello(scene, transform, context, render_params), @@ -680,23 +1018,36 @@ impl Render for Graphic { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option, inherited_appearance: Option<&Appearance>) { if let Some(element_id) = element_id { + // The footprint always lands; the transform (and for vectors the source layer) comes from the first item when one exists + let first_item_inserts = |metadata: &mut RenderMetadata, transform: DAffine2| { + metadata.upstream_footprints.insert(element_id, footprint); + metadata.local_transforms.insert(element_id, transform); + }; + match self { - Graphic::None => {} - Graphic::GraphicList(_) => { + Graphic::None(_) | Graphic::NoneList(_) => {} + Graphic::Graphic(_) | Graphic::GraphicList(_) => { metadata.upstream_footprints.insert(element_id, footprint); } + Graphic::Vector(item) => { + first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)); + metadata.first_element_source_id.insert(element_id, ItemRef::Item(item).layer()); + } Graphic::VectorList(list) => { metadata.upstream_footprints.insert(element_id, footprint); // TODO: Find a way to handle more than the first item if !list.is_empty() { - let layer_path: List = list.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, 0).0; - let layer = layer_path.iter_element_values().next_back().copied(); let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - metadata.first_element_source_id.insert(element_id, layer); + metadata.first_element_source_id.insert(element_id, ItemRef::ListItem(list, 0).layer()); metadata.local_transforms.insert(element_id, transform); } } + Graphic::RasterCPU(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::RasterGPU(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::Color(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::Gradient(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), + Graphic::Text(item) => first_item_inserts(metadata, item.attribute_cloned_or_default(ATTR_TRANSFORM)), Graphic::RasterCPUList(list) => { metadata.upstream_footprints.insert(element_id, footprint); @@ -741,7 +1092,14 @@ impl Render for Graphic { } match self { - Graphic::None => (), + Graphic::None(_) | Graphic::NoneList(_) => (), + Graphic::Graphic(item) => collect_graphic_items_metadata(std::iter::once(ItemRef::Item(item.as_ref())), metadata, footprint, element_id, inherited_appearance), + Graphic::Vector(item) => collect_vector_items_metadata(std::iter::once(ItemRef::Item(item.as_ref())), metadata, footprint, element_id, inherited_appearance), + Graphic::RasterCPU(item) => collect_raster_metadata(Some(ItemRef::Item(item)), metadata, footprint, element_id), + Graphic::RasterGPU(item) => collect_raster_metadata(Some(ItemRef::Item(item)), metadata, footprint, element_id), + Graphic::Color(_) => (), + Graphic::Gradient(item) => collect_gradient_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, element_id), + Graphic::Text(item) => collect_text_items_metadata(std::iter::once(ItemRef::Item(item)), metadata, footprint, element_id), Graphic::GraphicList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::VectorList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), Graphic::RasterCPUList(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance), @@ -754,7 +1112,14 @@ impl Render for Graphic { fn add_upstream_click_targets(&self, click_targets: &mut Vec, inherited_appearance: Option<&Appearance>) { match self { - Graphic::None => (), + Graphic::None(_) | Graphic::NoneList(_) => (), + Graphic::Graphic(item) => add_graphic_item_click_targets(ItemRef::Item(item), click_targets, inherited_appearance), + Graphic::Vector(item) => add_vector_item_click_targets(ItemRef::Item(item), click_targets, inherited_appearance), + Graphic::RasterCPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), click_targets), + Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), click_targets), + Graphic::Color(_) => (), + Graphic::Gradient(item) => add_gradient_item_click_targets(ItemRef::Item(item), click_targets), + Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), click_targets), Graphic::GraphicList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), Graphic::RasterCPUList(list) => list.add_upstream_click_targets(click_targets, inherited_appearance), @@ -767,7 +1132,14 @@ impl Render for Graphic { fn add_upstream_outline_targets(&self, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { match self { - Graphic::None => (), + Graphic::None(_) | Graphic::NoneList(_) => (), + Graphic::Graphic(item) => add_graphic_item_outline_targets(ItemRef::Item(item), outlines, inherited_appearance), + Graphic::Vector(item) => add_vector_item_outline_targets(ItemRef::Item(item), outlines, inherited_appearance), + Graphic::RasterCPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), outlines), + Graphic::RasterGPU(item) => add_unit_square_click_target(item.attribute_cloned_or_default(ATTR_TRANSFORM), outlines), + Graphic::Color(_) => (), + Graphic::Gradient(item) => add_gradient_item_outline_targets(ItemRef::Item(item), outlines), + Graphic::Text(item) => add_text_item_click_targets(ItemRef::Item(item), outlines), Graphic::GraphicList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::VectorList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), Graphic::RasterCPUList(list) => list.add_upstream_outline_targets(outlines, inherited_appearance), @@ -780,27 +1152,22 @@ impl Render for Graphic { fn contains_artboard(&self) -> bool { match self { - Graphic::None => false, + Graphic::Graphic(item) => item.element().contains_artboard(), Graphic::GraphicList(list) => list.contains_artboard(), - Graphic::VectorList(list) => list.contains_artboard(), - Graphic::RasterCPUList(list) => list.contains_artboard(), - Graphic::RasterGPUList(list) => list.contains_artboard(), - Graphic::ColorList(list) => list.contains_artboard(), - Graphic::GradientList(list) => list.contains_artboard(), - Graphic::TextList(list) => list.contains_artboard(), + _ => false, } } fn new_ids_from_hash(&mut self, reference: Option) { match self { - Graphic::None => (), + Graphic::Graphic(item) => { + let layer = ItemRef::Item(item).layer(); + item.element_mut().new_ids_from_hash(layer); + } + Graphic::Vector(item) => item.element_mut().vector_new_ids_from_hash(reference.map(|id| id.0).unwrap_or_default()), Graphic::GraphicList(list) => list.new_ids_from_hash(reference), Graphic::VectorList(list) => list.new_ids_from_hash(reference), - Graphic::RasterCPUList(_) => (), - Graphic::RasterGPUList(_) => (), - Graphic::ColorList(_) => (), - Graphic::GradientList(_) => (), - Graphic::TextList(_) => (), + _ => (), } } } @@ -947,73 +1314,8 @@ impl Render for List { let mut mask_state = None; for index in 0..self.len() { - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let blend_mode: 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.); - let element = self.element(index).unwrap(); - // This item's declared appearance (if any) cascades to descendants lacking their own - let child_render_params = self - .attribute::(ATTR_APPEARANCE, index) - .and_then(Appearance::declared) - .map(|appearance| render_params.for_child_item(appearance)); - let render_params = child_render_params.as_ref().unwrap_or(render_params); - - let matrix = format_transform_matrix(transform); let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); - let mut masked_by = None; - - if next_clips && mask_state.is_none() { - let uuid = generate_uuid(); - let mask_type = if element.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; - - let mut svg = SvgRender::new(); - element.render_svg(&mut svg, &render_params.for_clipper()); - - // The def is resolved in this list's space, so the masker's own transform has to be baked into it - let masker = match matrix.is_empty() { - true => svg.svg.to_svg_string(), - false => format!(r##"{}"##, svg.svg.to_svg_string()), - }; - - render.svg_defs.push_str(&svg.svg_defs); - mask_type.write_to_defs(&mut render.svg_defs, uuid, masker); - - mask_state = Some((uuid, mask_type)); - } else if let Some((uuid, mask_type)) = mask_state { - if !next_clips { - mask_state = None; - } - - masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); - } - - let render_item = |render: &mut SvgRender| { - render.parent_tag( - "g", - |attributes| { - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix.clone()); - } - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - }, - |render| element.render_svg(render, render_params), - ); - }; - - // The mask rides an untransformed wrapper so it resolves in this list's space rather than the item's own - match masked_by { - Some((attribute, selector)) => render.parent_tag("g", |attributes| attributes.push(attribute, selector), render_item), - None => render_item(render), - } + render_graphic_item_svg(ItemRef::ListItem(self, index), next_clips, &mut mask_state, render, render_params); } } @@ -1021,168 +1323,24 @@ impl Render for List { let mut mask_element_and_transform = None; for index in 0..self.len() { - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let transform = transform * item_transform; - 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.); - let element = self.element(index).unwrap(); - // This item's declared appearance (if any) cascades to descendants lacking their own - let child_render_params = self - .attribute::(ATTR_APPEARANCE, index) - .and_then(Appearance::declared) - .map(|appearance| render_params.for_child_item(appearance)); - let render_params = child_render_params.as_ref().unwrap_or(render_params); - - let mut layer = false; - - let blend_mode = match render_params.render_mode { - RenderMode::Outline => peniko::Mix::Normal, - _ => blend_mode_attr.to_peniko(), - }; - let mut bounds = RenderBoundingBox::None; - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default()) { - bounds = element.bounding_box(transform, true); - - if let RenderBoundingBox::Rectangle(bounds) = bounds { - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), - opacity, - kurbo::Affine::IDENTITY, - &kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), - ); - layer = true; - } - } - let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); - if next_clips && mask_element_and_transform.is_none() { - mask_element_and_transform = Some((element, transform)); - - element.render_to_vello(scene, transform, context, render_params); - } else if let Some((mask_element, transform_mask)) = mask_element_and_transform { - if !next_clips { - mask_element_and_transform = None; - } - if !layer { - bounds = element.bounding_box(transform, true); - } - - if let RenderBoundingBox::Rectangle(bounds) = bounds { - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect); - mask_element.render_to_vello(scene, transform_mask, context, &render_params.for_clipper()); - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), - 1., - kurbo::Affine::IDENTITY, - &rect, - ); - } - - element.render_to_vello(scene, transform, context, render_params); - - if matches!(bounds, RenderBoundingBox::Rectangle(_)) { - scene.pop_layer(); - scene.pop_layer(); - } - } else { - element.render_to_vello(scene, transform, context, render_params); - } - - if layer { - scene.pop_layer(); - } + render_graphic_item_to_vello(ItemRef::ListItem(self, index), next_clips, &mut mask_element_and_transform, scene, transform, context, render_params); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option, inherited_appearance: Option<&Appearance>) { - for index in 0..self.len() { - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let layer_path: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; - let layer = layer_path.iter_element_values().next_back().copied(); - let element = self.element(index).unwrap(); - // This item's appearance (if any) cascades to descendants lacking their own - let child_appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - - let mut footprint = footprint; - footprint.transform *= item_transform; - - if let Some(element_id) = layer { - element.collect_metadata(metadata, footprint, Some(element_id), child_appearance); - } else { - // Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags - element.collect_metadata(metadata, footprint, None, child_appearance); - } - } - - if let Some(element_id) = element_id { - let mut all_upstream_click_targets = Vec::new(); - let mut all_upstream_outlines = Vec::new(); - - for index in 0..self.len() { - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let element = self.element(index).unwrap(); - let child_appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - - let mut new_click_targets = Vec::new(); - element.add_upstream_click_targets(&mut new_click_targets, child_appearance); - - for click_target in new_click_targets.iter_mut() { - click_target.apply_transform(item_transform) - } - - all_upstream_click_targets.extend(new_click_targets); - - let mut new_outlines = Vec::new(); - element.add_upstream_outline_targets(&mut new_outlines, child_appearance); - for outline in new_outlines.iter_mut() { - outline.apply_transform(item_transform) - } - all_upstream_outlines.extend(new_outlines); - } - - metadata.click_targets.insert(element_id, all_upstream_click_targets.into_iter().map(|x| x.into()).collect()); - metadata.outlines.insert(element_id, all_upstream_outlines.into_iter().map(|x| x.into()).collect()); - } + collect_graphic_items_metadata((0..self.len()).map(|index| ItemRef::ListItem(self, index)), metadata, footprint, element_id, inherited_appearance); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let element = self.element(index).unwrap(); - let child_appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - let mut new_click_targets = Vec::new(); - - element.add_upstream_click_targets(&mut new_click_targets, child_appearance); - - for click_target in new_click_targets.iter_mut() { - click_target.apply_transform(item_transform) - } - - click_targets.extend(new_click_targets); + add_graphic_item_click_targets(ItemRef::ListItem(self, index), click_targets, inherited_appearance); } } fn add_upstream_outline_targets(&self, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let element = self.element(index).unwrap(); - let child_appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - let mut new_outlines = Vec::new(); - - element.add_upstream_outline_targets(&mut new_outlines, child_appearance); - - for outline in new_outlines.iter_mut() { - outline.apply_transform(item_transform) - } - - outlines.extend(new_outlines); + add_graphic_item_outline_targets(ItemRef::ListItem(self, index), outlines, inherited_appearance); } } @@ -1198,15 +1356,15 @@ impl Render for List { } } -/// Emits one item of a `List` as SVG, with no wrapping group of its own. -fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) { - let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let blend_mode_attr: BlendMode = list.attribute_cloned_or_default(ATTR_BLEND_MODE, index); - let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.); - let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); +/// Emits one vector shape as SVG, with no wrapping group of its own. +fn render_vector_shape_svg(item: ItemRef<'_, Vector>, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) { + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); // The item's own declared appearance wins over one cascading down from an ancestor - let own_appearance = list.attribute::(ATTR_APPEARANCE, index).and_then(Appearance::declared); + let own_appearance = item.attribute::(ATTR_APPEARANCE).and_then(Appearance::declared); let appearance = own_appearance.or(render_params.inherited_appearance.as_ref()); let FillAndStroke { stroke: stroke_params, @@ -1253,8 +1411,8 @@ fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, re let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); let can_draw_aligned_stroke = path_is_closed && stroke_params.as_ref().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); + && stroke_graphic.is_some_and(|graphic| !graphic.is_guaranteed_fully_transparent()); + let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.is_guaranteed_to_cover_opaquely()) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; @@ -1355,7 +1513,7 @@ fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, re .unwrap_or_default(); // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback - let stroke_visible = stroke_params.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); + let stroke_visible = stroke_params.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_guaranteed_fully_transparent()); let stroke_attribute = if stroke_visible { stroke_graphic_list .as_ref() @@ -1363,7 +1521,7 @@ fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, re // 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::ColorList(_)) | Some(Graphic::GradientList(_)) => bounds_matrix, + Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) | Some(Graphic::ColorList(_)) | Some(Graphic::GradientList(_)) => bounds_matrix, _ => stroke_bounds_matrix, }; list.render(defs, item_transform, element_transform, applied_stroke_transform, paint_bounds, &render_params, PaintTarget::Stroke) @@ -1419,45 +1577,526 @@ fn render_vector_item_svg(list: &List, index: usize, vector: &Vector, re } } +/// Emits one item of vector content as SVG, handling the sibling clipping run carried in `clip_mask_state`. +/// A lone item has no siblings, so both mask inputs stay inert. +fn render_vector_item_svg(item: ItemRef<'_, Vector>, next_clips: bool, clip_mask_state: &mut Option<(u64, MaskType)>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(vector) = item.element() else { return }; + + let mut masked_by = None; + + if next_clips && clip_mask_state.is_none() { + let masker = Graphic::VectorList(List::new_from_item(Item::from_parts(vector.clone(), item.clone_item_attributes()))); + let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; + let uuid = generate_uuid(); + + let mut masker_svg = SvgRender::new(); + masker.render_svg(&mut masker_svg, &render_params.for_clipper()); + render.svg_defs.push_str(&masker_svg.svg_defs); + mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string()); + + *clip_mask_state = Some((uuid, mask_type)); + } else if let Some((uuid, mask_type)) = *clip_mask_state { + if !next_clips { + *clip_mask_state = None; + } + + masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); + } + + // Item geometry is baked into the path data instead of a group transform, so mask coordinates line up + match masked_by { + Some((attribute, selector)) => render.parent_tag( + "g", + |attributes| attributes.push(attribute, selector), + |render| render_vector_shape_svg(item, vector, render, render_params), + ), + None => render_vector_shape_svg(item, vector, render, render_params), + } +} + +/// Draws one item of vector content into the Vello scene: fill and stroke paints, blend and opacity layering, +/// stroke alignment compositing, and the sibling clipping run carried in `clip_masker` (inert for a lone item). +#[allow(clippy::too_many_arguments)] +fn render_vector_item_to_vello( + item: ItemRef<'_, Vector>, + next_clips: bool, + clip_masker: &mut Option>, + scene: &mut Scene, + parent_transform: DAffine2, + context: &mut RenderContext, + render_params: &RenderParams, + paint_render_params: &RenderParams, +) { + let Some(element) = item.element() else { return }; + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let multiplied_transform = parent_transform * item_transform; + + // The item's own declared appearance wins over one cascading down from an ancestor + let own_appearance = item.attribute::(ATTR_APPEARANCE).and_then(Appearance::declared); + let appearance = own_appearance.or(render_params.inherited_appearance.as_ref()); + let FillAndStroke { + stroke: stroke_params, + fill_paint, + stroke_paint, + stroke_below: wants_stroke_below, + } = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + let fill_graphic_list: Option> = fill_paint.map(|paint| List::new_from_element(paint.clone())); + let stroke_graphic_list: Option> = stroke_paint.map(|paint| List::new_from_element(paint.clone())); + + let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.); + // A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own + let set_stroke_transform = has_real_stroke + .map(|stroke| if own_appearance.is_some() { stroke.transform } else { item_transform }) + .filter(|transform| transform_is_invertible(*transform)); + let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); + let mut element_transform = set_stroke_transform + .map(|stroke_transform| multiplied_transform * stroke_transform.inverse()) + .unwrap_or(DAffine2::IDENTITY); + if let Some(alignment_transform) = render_params.alignment_parent_transform { + applied_stroke_transform = alignment_transform; + element_transform = if transform_is_invertible(alignment_transform) { + multiplied_transform * alignment_transform.inverse() + } else { + multiplied_transform + }; + } + let layer_bounds = element.bounding_box().unwrap_or_default(); + + let mut path = kurbo::BezPath::new(); + for mut bezpath in element.stroke_bezpath_iter() { + bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); + for element in bezpath { + path.push(element); + } + } + + // 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, + _ => blend_mode_attr.to_peniko(), + }; + let mut layer = false; + + // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). + // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since + // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. + let stroke = stroke_params.as_ref(); + let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_guaranteed_fully_transparent())); + let can_draw_aligned_stroke = !stroke_fully_transparent && stroke.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; + let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + + // Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed + let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| { + // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. + // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. + let (_, smallest_scale) = singular_values(applied_stroke_transform); + let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); + let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. }; + let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box(); + + ( + kurbo::Affine::new(multiplied_transform.to_cols_array()), + kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), + ) + }); + + if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry { + layer = true; + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect); + } + + // Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result + let mut clip_layers = false; + if next_clips && clip_masker.is_none() { + *clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), item.clone_item_attributes()))); + } else if let Some(masker) = clip_masker.as_ref() { + if let Some((layer_affine, layer_rect)) = layer_geometry { + scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect); + masker.render_to_vello(scene, parent_transform, context, &render_params.for_clipper()); + scene.push_layer( + peniko::Fill::NonZero, + peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), + 1., + layer_affine, + &layer_rect, + ); + clip_layers = true; + } + + if !next_clips { + *clip_masker = None; + } + } + + let use_layer = can_draw_aligned_stroke; + + 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_ref() else { return }; + + for paint_index in 0..fill_graphic.len() { + let Some(paint) = fill_graphic.element(paint_index) else { continue }; + let solid_fill = |scene: &mut Scene, color: &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); + }; + let gradient_fill = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else { + return; + }; + + let inverse_element_transform = if transform_is_invertible(element_transform) { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); + scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); + }; + + match paint { + Graphic::None(_) | Graphic::NoneList(_) => continue, + Graphic::Color(item) => solid_fill(scene, item.element()), + Graphic::ColorList(list) => { + let Some(color) = list.element(0) else { continue }; + solid_fill(scene, color); + } + Graphic::Gradient(item) => gradient_fill(scene, ItemRef::Item(item)), + Graphic::GradientList(list) => gradient_fill(scene, ItemRef::ListItem(list, 0)), + // Any other graphic content paints as a texture clipped to the path + Graphic::Graphic(_) + | Graphic::Vector(_) + | Graphic::RasterCPU(_) + | Graphic::RasterGPU(_) + | Graphic::Text(_) + | Graphic::VectorList(_) + | Graphic::RasterCPUList(_) + | Graphic::RasterGPUList(_) + | Graphic::GraphicList(_) + | Graphic::TextList(_) => { + scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); + paint.render_to_vello(scene, multiplied_transform, context, paint_render_params); + scene.pop_layer(); + } + }; + } + }; + + // 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, 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())); + let mut kurbo_path = kurbo::BezPath::new(); + for element in face_path { + kurbo_path.push(element); + } + do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); + } + } else if element.is_branching() { + do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); + } else { + do_fill_path(scene, context, &path, peniko::Fill::NonZero); + } + }; + + let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { + let Some(stroke_graphic_list) = stroke_graphic_list.as_ref() else { return }; + let Some(stroke) = 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, + StrokeCap::Square => Cap::Square, + }; + let join = match stroke.join { + StrokeJoin::Miter => Join::Miter, + StrokeJoin::Bevel => Join::Bevel, + StrokeJoin::Round => Join::Round, + }; + let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); + let stroke = kurbo::Stroke { + width: stroke.weight * width_scale, + miter_limit: stroke.join_miter_limit, + join, + start_cap: cap, + end_cap: cap, + dash_pattern, + dash_offset: stroke.dash_offset, + }; + + if stroke.width <= 0. { + continue; + }; + + let solid_stroke = |scene: &mut Scene, color: &Color| { + 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); + }; + let gradient_stroke = |scene: &mut Scene, gradient_item: ItemRef<'_, Gradient>| { + let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(gradient_item, &multiplied_transform) else { + return; + }; + let inverse_element_transform = if transform_is_invertible(element_transform) { + element_transform.inverse() + } else { + Default::default() + }; + let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); + + scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); + }; + + match stroke_graphic { + Graphic::None(_) | Graphic::NoneList(_) => continue, + Graphic::Color(item) => solid_stroke(scene, item.element()), + Graphic::ColorList(list) => { + let Some(color) = list.element(0) else { continue }; + solid_stroke(scene, color); + } + Graphic::Gradient(item) => gradient_stroke(scene, ItemRef::Item(item)), + Graphic::GradientList(list) => gradient_stroke(scene, ItemRef::ListItem(list, 0)), + // Any other graphic content paints as a texture clipped to the stroked region + Graphic::Graphic(_) + | Graphic::Vector(_) + | Graphic::RasterCPU(_) + | Graphic::RasterGPU(_) + | Graphic::Text(_) + | Graphic::VectorList(_) + | Graphic::RasterCPUList(_) + | Graphic::RasterGPUList(_) + | Graphic::GraphicList(_) + | Graphic::TextList(_) => { + 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, paint_render_params); + scene.pop_layer(); + } + }; + } + }; + + // Render the path + match render_params.render_mode { + RenderMode::Outline => { + let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); + + scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); + } + _ => { + if use_layer { + let cloned_element = element.clone(); + + // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. + // The outer opacity/blend layer (above) handles the user-set opacity. + let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform); + let black_fill = Graphic::ColorList(List::new_from_element(Color::BLACK)); + mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill)); + let vector_list = List::new_from_item(mask_item); + + let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); + // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed + let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); + let (largest_scale, _) = singular_values(applied_stroke_transform); + let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); + let bounds = quad.bounding_box(); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + + let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { + peniko::Compose::SrcOut + } else { + peniko::Compose::SrcIn + }; + + 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)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke(scene, 2., context); + + scene.pop_layer(); + scene.pop_layer(); + + do_fill(scene, context); + } else { + // Fill first (unclipped), then stroke (clipped) above + 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)); + scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); + + do_stroke(scene, 2., context); + + scene.pop_layer(); + scene.pop_layer(); + } + } else { + // Non-aligned strokes or open paths: default order behavior + enum Op { + Fill, + Stroke, + } + + let order = match wants_stroke_below { + true => [Op::Stroke, Op::Fill], + false => [Op::Fill, Op::Stroke], // Default + }; + + for operation in &order { + match operation { + Op::Fill => do_fill(scene, context), + Op::Stroke => do_stroke(scene, 1., context), + } + } + } + } + } + + if clip_layers { + scene.pop_layer(); + scene.pop_layer(); + } + + // If we pushed a layer for opacity or a blend mode, we need to pop it + if layer { + scene.pop_layer(); + } +} + +/// The full metadata pass over a run of vector items. +/// Aggregates all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph. +/// Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry. +fn collect_vector_items_metadata<'a>( + items: impl Iterator>, + metadata: &mut RenderMetadata, + footprint: Footprint, + caller_element_id: Option, + inherited_appearance: Option<&Appearance>, +) { + let mut reference_transforms: HashMap = HashMap::new(); + + let mut accumulated_click_targets: HashMap>> = HashMap::new(); + let mut accumulated_outlines: HashMap>> = HashMap::new(); + + for item in items { + let Some(source) = item.element() else { continue }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + // The item's own appearance wins over one cascading down from an ancestor + let appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + + if let Some(element_id) = caller_element_id.or(item.layer()) { + let reference_transform = *reference_transforms.entry(element_id).or_insert(transform); + let reference_inverse = if transform_is_invertible(reference_transform) { + reference_transform.inverse() + } else { + DAffine2::IDENTITY + }; + + // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) + let click_target_vector = item.attribute::(ATTR_EDITOR_CLICK_TARGET).unwrap_or(source); + + let item_relative_transform = reference_inverse * transform; + + let mut click_targets_unwrapped = Vec::new(); + extend_targets_from_vector(&mut click_targets_unwrapped, appearance, 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, appearance, 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) item so stroke geometry stays consistent with the paint. + // Only item 0 is recorded since editing tools can only target a single item currently. + // If that item has no paint attribute, none is recorded. + if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { + e.insert(Arc::new(source.clone())); + + if let Some(appearance) = appearance { + metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone())); + } + } + + // Surface `editor:text_frame` for the Text tool's drag cage + if let Some(&frame) = item.attribute::(ATTR_EDITOR_TEXT_FRAME) { + metadata.text_frames.entry(element_id).or_insert(frame); + } + } + + // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, + // Combine Paths, Morph, or any other destructive merge), recurse into that snapshot so the editor can + // surface the original child layers' click targets. + let upstream_nested_layers = item.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS); + if !upstream_nested_layers.is_empty() { + let mut upstream_footprint = footprint; + upstream_footprint.transform *= transform; + // Snapshot layers carry their own styling, so the merged result's appearance must not cascade into them + upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None, None); + } + } + + // Overwrite with the full accumulated set (not just item 0's contribution) + for (element_id, targets) in accumulated_click_targets { + metadata.click_targets.insert(element_id, targets); + } + for (element_id, targets) in accumulated_outlines { + metadata.outlines.insert(element_id, targets); + } + + // Recovering element_id from `editor:layer_path` means `Graphic::collect_metadata` skipped this transform metadata. + // It lands after the snapshot recursion above so each element keeps the pair its targets were baked against. + if caller_element_id.is_none() { + for (element_id, reference_transform) in reference_transforms { + metadata.upstream_footprints.insert(element_id, footprint); + metadata.local_transforms.insert(element_id, reference_transform); + } + } +} + +/// Collects one vector item's click target into the caller's list, baked through the item's transform. +fn add_vector_item_click_targets(item: ItemRef<'_, Vector>, click_targets: &mut Vec, inherited_appearance: Option<&Appearance>) { + let Some(source) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + + // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) + let vector = item.attribute::(ATTR_EDITOR_CLICK_TARGET).unwrap_or(source); + + extend_targets_from_vector(click_targets, appearance, vector, transform); +} + +/// Like [`add_vector_item_click_targets`] but on source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms. +fn add_vector_item_outline_targets(item: ItemRef<'_, Vector>, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { + let Some(source) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let appearance = Appearance::cascade(item.attribute::(ATTR_APPEARANCE), inherited_appearance); + + extend_targets_from_vector(outlines, appearance, source, transform); +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { let mut clip_mask_state: Option<(u64, MaskType)> = None; for index in 0..self.len() { - let Some(vector) = self.element(index) else { continue }; - // A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::(ATTR_CLIPPING_MASK, index + 1); - let mut masked_by = None; - - if next_clips && clip_mask_state.is_none() { - let masker = Graphic::VectorList(List::new_from_item(Item::from_parts(vector.clone(), self.clone_item_attributes(index)))); - let mask_type = if masker.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask }; - let uuid = generate_uuid(); - - let mut masker_svg = SvgRender::new(); - masker.render_svg(&mut masker_svg, &render_params.for_clipper()); - render.svg_defs.push_str(&masker_svg.svg_defs); - mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string()); - - clip_mask_state = Some((uuid, mask_type)); - } else if let Some((uuid, mask_type)) = clip_mask_state { - if !next_clips { - clip_mask_state = None; - } - - masked_by = Some((mask_type.to_attribute(), format!("url(#mask-{uuid})"))); - } - - // Item geometry is baked into the path data instead of a group transform, so mask coordinates line up - match masked_by { - Some((attribute, selector)) => render.parent_tag( - "g", - |attributes| attributes.push(attribute, selector), - |render| render_vector_item_svg(self, index, vector, render, render_params), - ), - None => render_vector_item_svg(self, index, vector, render, render_params), - } + render_vector_item_svg(ItemRef::ListItem(self, index), next_clips, &mut clip_mask_state, render, render_params); } } @@ -1471,435 +2110,39 @@ impl Render for List { }; for index in 0..self.len() { - let Some(element) = self.element(index) else { continue }; - 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.); - let multiplied_transform = parent_transform * item_transform; - - // The item's own declared appearance wins over one cascading down from an ancestor - let own_appearance = self.attribute::(ATTR_APPEARANCE, index).and_then(Appearance::declared); - let appearance = own_appearance.or(render_params.inherited_appearance.as_ref()); - let FillAndStroke { - stroke: stroke_params, - fill_paint, - stroke_paint, - stroke_below: wants_stroke_below, - } = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); - let fill_graphic_list: Option> = fill_paint.map(|paint| List::new_from_element(paint.clone())); - let stroke_graphic_list: Option> = stroke_paint.map(|paint| List::new_from_element(paint.clone())); - - let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.); - // A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own - let set_stroke_transform = has_real_stroke - .map(|stroke| if own_appearance.is_some() { stroke.transform } else { item_transform }) - .filter(|transform| transform_is_invertible(*transform)); - let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); - let mut element_transform = set_stroke_transform - .map(|stroke_transform| multiplied_transform * stroke_transform.inverse()) - .unwrap_or(DAffine2::IDENTITY); - if let Some(alignment_transform) = render_params.alignment_parent_transform { - applied_stroke_transform = alignment_transform; - element_transform = if transform_is_invertible(alignment_transform) { - multiplied_transform * alignment_transform.inverse() - } else { - multiplied_transform - }; - } - let layer_bounds = element.bounding_box().unwrap_or_default(); - - let mut path = kurbo::BezPath::new(); - for mut bezpath in element.stroke_bezpath_iter() { - bezpath.apply_affine(Affine::new(applied_stroke_transform.to_cols_array())); - for element in bezpath { - path.push(element); - } - } - - // 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, - _ => blend_mode_attr.to_peniko(), - }; - let mut layer = false; - - // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). - // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since - // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. - let stroke = stroke_params.as_ref(); - let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent())); - let can_draw_aligned_stroke = - !stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); - - // A clip-flagged item is masked by its nearest preceding unflagged sibling, which a consecutive run shares let next_clips = index + 1 < self.len() && self.attribute_cloned_or_default::(ATTR_CLIPPING_MASK, index + 1); - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let needs_blend_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); - - // Shared by the blend and clipping layers below, so it is only worth deriving when one of them is pushed - let layer_geometry = (needs_blend_layer || clip_masker.is_some()).then(|| { - // `max_aabb_inflation` is in `applied_stroke_transform`-space; `layer_bounds` is path-local and `push_layer` re-applies `multiplied_transform`. - // Divide by the smaller axial scale to cover the stroke in both axes after Vello's transform. Skip on a degenerate transform. - let (_, smallest_scale) = singular_values(applied_stroke_transform); - let stroke_inflation = stroke.map_or(0., |s| s.max_aabb_inflation(can_draw_aligned_stroke)); - let inflate_amount = if smallest_scale > 0. { stroke_inflation / smallest_scale } else { 0. }; - let bounds = Quad::from_box(layer_bounds).inflate(inflate_amount).bounding_box(); - - ( - kurbo::Affine::new(multiplied_transform.to_cols_array()), - kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), - ) - }); - - if needs_blend_layer && let Some((layer_affine, layer_rect)) = layer_geometry { - layer = true; - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), opacity, layer_affine, &layer_rect); - } - - // Pushed inside the blend layer so the mask cuts this item's own paint rather than the composited result - let mut clip_layers = false; - if next_clips && clip_masker.is_none() { - clip_masker = Some(List::new_from_item(Item::from_parts(element.clone(), self.clone_item_attributes(index)))); - } else if let Some(masker) = clip_masker.as_ref() { - if let Some((layer_affine, layer_rect)) = layer_geometry { - scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect); - masker.render_to_vello(scene, parent_transform, context, &render_params.for_clipper()); - scene.push_layer( - peniko::Fill::NonZero, - peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn), - 1., - layer_affine, - &layer_rect, - ); - clip_layers = true; - } - - if !next_clips { - clip_masker = None; - } - } - - let use_layer = can_draw_aligned_stroke; - - 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_ref() else { return }; - - for paint_index in 0..fill_graphic.len() { - let Some(paint) = fill_graphic.element(paint_index) else { continue }; - match paint { - Graphic::None => continue, - Graphic::ColorList(list) => { - let Some(color) = list.element(0) else { continue }; - - 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::GradientList(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { - continue; - }; - - let inverse_element_transform = if transform_is_invertible(element_transform) { - element_transform.inverse() - } else { - Default::default() - }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); - scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path); - } - Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) => { - scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); - paint.render_to_vello(scene, multiplied_transform, context, &paint_render_params); - scene.pop_layer(); - } - }; - } - }; - - // 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, 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())); - let mut kurbo_path = kurbo::BezPath::new(); - for element in face_path { - kurbo_path.push(element); - } - do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero); - } - } else if element.is_branching() { - do_fill_path(scene, context, &path, peniko::Fill::EvenOdd); - } else { - do_fill_path(scene, context, &path, peniko::Fill::NonZero); - } - }; - - let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { - let Some(stroke_graphic_list) = stroke_graphic_list.as_ref() else { return }; - let Some(stroke) = 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, - StrokeCap::Square => Cap::Square, - }; - let join = match stroke.join { - StrokeJoin::Miter => Join::Miter, - StrokeJoin::Bevel => Join::Bevel, - StrokeJoin::Round => Join::Round, - }; - let dash_pattern = stroke.dash_lengths.iter().map(|l| l.max(0.)).collect(); - let stroke = kurbo::Stroke { - width: stroke.weight * width_scale, - miter_limit: stroke.join_miter_limit, - join, - start_cap: cap, - end_cap: cap, - dash_pattern, - dash_offset: stroke.dash_offset, - }; - - if stroke.width <= 0. { - continue; - }; - - match stroke_graphic { - Graphic::None => continue, - Graphic::ColorList(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::GradientList(list) => { - let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(list, &multiplied_transform) else { - continue; - }; - let inverse_element_transform = if transform_is_invertible(element_transform) { - element_transform.inverse() - } else { - Default::default() - }; - let brush_transform = kurbo::Affine::new((inverse_element_transform * gradient_to_device).to_cols_array()); - - scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path); - } - Graphic::VectorList(_) | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::GraphicList(_) | Graphic::TextList(_) => { - 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, &paint_render_params); - scene.pop_layer(); - } - }; - } - }; - - // Render the path - match render_params.render_mode { - RenderMode::Outline => { - let (outline_stroke, outline_color_peniko) = get_outline_styles(render_params); - - scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color_peniko, None, &path); - } - _ => { - if use_layer { - let cloned_element = element.clone(); - - // The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. - // The outer opacity/blend layer (above) handles the user-set opacity. - let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform); - let black_fill = Graphic::ColorList(List::new_from_element(Color::BLACK)); - mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill)); - let vector_list = List::new_from_item(mask_item); - - let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); - // This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed - let inflation = stroke.map_or(0., |stroke| stroke.max_aabb_inflation(true)); - let (largest_scale, _) = singular_values(applied_stroke_transform); - let quad = Quad::from_box(bounds).inflate(inflation * largest_scale); - let bounds = quad.bounding_box(); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - - let compose = if stroke.is_some_and(|x| x.align == StrokeAlign::Outside) { - peniko::Compose::SrcOut - } else { - peniko::Compose::SrcIn - }; - - 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)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - - do_stroke(scene, 2., context); - - scene.pop_layer(); - scene.pop_layer(); - - do_fill(scene, context); - } else { - // Fill first (unclipped), then stroke (clipped) above - 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)); - scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect); - - do_stroke(scene, 2., context); - - scene.pop_layer(); - scene.pop_layer(); - } - } else { - // Non-aligned strokes or open paths: default order behavior - enum Op { - Fill, - Stroke, - } - - let order = match wants_stroke_below { - true => [Op::Stroke, Op::Fill], - false => [Op::Fill, Op::Stroke], // Default - }; - - for operation in &order { - match operation { - Op::Fill => do_fill(scene, context), - Op::Stroke => do_stroke(scene, 1., context), - } - } - } - } - } - - if clip_layers { - scene.pop_layer(); - scene.pop_layer(); - } - - // If we pushed a layer for opacity or a blend mode, we need to pop it - if layer { - scene.pop_layer(); - } + render_vector_item_to_vello( + ItemRef::ListItem(self, index), + next_clips, + &mut clip_masker, + scene, + parent_transform, + context, + render_params, + &paint_render_params, + ); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option, inherited_appearance: Option<&Appearance>) { - // Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph. - // Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry. - let mut reference_transforms: HashMap = HashMap::new(); - - let mut accumulated_click_targets: HashMap>> = HashMap::new(); - let mut accumulated_outlines: HashMap>> = HashMap::new(); - - for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let layer_path: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; - let layer = layer_path.iter_element_values().next_back().copied(); - // The item's own appearance wins over one cascading down from an ancestor - let appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - - if let Some(element_id) = caller_element_id.or(layer) { - let reference_transform = *reference_transforms.entry(element_id).or_insert(transform); - let reference_inverse = if transform_is_invertible(reference_transform) { - reference_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - // Use click-target override if the item provides one (e.g. 'Text' node's per-glyph bboxes) - let click_target_vector = self.attribute::(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); - - let item_relative_transform = reference_inverse * transform; - - let mut click_targets_unwrapped = Vec::new(); - extend_targets_from_vector(&mut click_targets_unwrapped, appearance, 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, appearance, 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 stroke geometry stays consistent with the paint. - // Only item 0 is recorded since editing tools can only target a single item currently. - // If that row has no paint attribute, none is recorded. - if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { - e.insert(Arc::new(source.clone())); - - if let Some(appearance) = appearance { - metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone())); - } - } - - // Surface `editor:text_frame` for the Text tool's drag cage - if let Some(&frame) = self.attribute::(ATTR_EDITOR_TEXT_FRAME, index) { - metadata.text_frames.entry(element_id).or_insert(frame); - } - } - - // If this item carries a snapshot of upstream graphic content (e.g. it was produced by Boolean Operation, - // Combine Paths, Morph, or any other destructive merge), recurse into that snapshot so the editor can - // surface the original child layers' click targets. - let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, index); - if !upstream_nested_layers.is_empty() { - let mut upstream_footprint = footprint; - upstream_footprint.transform *= transform; - // Snapshot layers carry their own styling, so the merged result's appearance must not cascade into them - upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None, None); - } - } - - // Overwrite with the full accumulated set (not just item 0's contribution) - for (element_id, targets) in accumulated_click_targets { - metadata.click_targets.insert(element_id, targets); - } - for (element_id, targets) in accumulated_outlines { - metadata.outlines.insert(element_id, targets); - } - - // Recovering element_id from `editor:layer_path` means `Graphic::collect_metadata` skipped this transform metadata. - // It lands after the snapshot recursion above so each element keeps the pair its targets were baked against. - if caller_element_id.is_none() { - for (element_id, reference_transform) in reference_transforms { - metadata.upstream_footprints.insert(element_id, footprint); - metadata.local_transforms.insert(element_id, reference_transform); - } - } + collect_vector_items_metadata( + (0..self.len()).map(|index| ItemRef::ListItem(self, index)), + metadata, + footprint, + caller_element_id, + inherited_appearance, + ); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - - // 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, appearance, vector, transform); + add_vector_item_click_targets(ItemRef::ListItem(self, index), click_targets, inherited_appearance); } } fn add_upstream_outline_targets(&self, outlines: &mut Vec, inherited_appearance: Option<&Appearance>) { - // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms for index in 0..self.len() { - let Some(source) = self.element(index) else { continue }; - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let appearance = Appearance::cascade(self.attribute::(ATTR_APPEARANCE, index), inherited_appearance); - - extend_targets_from_vector(outlines, appearance, source, transform); + add_vector_item_outline_targets(ItemRef::ListItem(self, index), outlines, inherited_appearance); } } @@ -1966,179 +2209,203 @@ fn extend_free_point_targets(vector: &Vector, transform: DAffine2) -> impl Itera }) } +/// Emits one item of CPU raster content as SVG, as a canvas placeholder or an embedded base64 image. +fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(image) = item.element() else { return }; + + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + + if image.data.is_empty() { + return; + } + + if render_params.to_canvas() { + let mut image_copy = image.clone(); + image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha()); + let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid); + + render.parent_tag( + "foreignObject", + |attributes| { + let size = DVec2::new(image.width as f64, image.height as f64); + + let matrix = transform * DAffine2::from_scale(1. / size); + let matrix = format_transform_matrix(matrix); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + + attributes.push("width", size.x.to_string()); + attributes.push("height", size.y.to_string()); + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }, + |render| { + render.leaf_tag( + "img", // Must be a self-closing (void element) tag, so we can't use `div` or `span`, for example + |attributes| { + attributes.push("data-canvas-placeholder", id.to_string()); + }, + ) + }, + ); + } else { + let base64_string = image.base64_string.clone().unwrap_or_else(|| { + use base64::Engine; + + let output = image.to_png(); + let preamble = "data:image/png;base64,"; + let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); + base64_string.push_str(preamble); + base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); + base64_string + }); + + render.leaf_tag("image", |attributes| { + attributes.push("width", "1"); + attributes.push("height", "1"); + attributes.push("preserveAspectRatio", "none"); + attributes.push("href", base64_string); + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push(ATTR_TRANSFORM, matrix); + } + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }); + } +} + +/// Draws one item of CPU raster content into the Vello scene. +fn render_raster_cpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut Scene, transform: DAffine2, render_params: &RenderParams) { + let Some(image) = item.element() else { return }; + if image.data.is_empty() { + return; + } + + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let blend_mode = blend_mode_attr.to_peniko(); + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let mut layer = false; + + let whole_bounds = || match item { + ItemRef::ListItem(list, _) => list.bounding_box(transform, false), + ItemRef::Item(item) => item.bounding_box(transform, false), + }; + if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default())) + && let RenderBoundingBox::Rectangle(bounds) = whole_bounds() + { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); + layer = true; + } + + let transform_attribute: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + if let RenderMode::Outline = render_params.render_mode { + let outline_transform: DAffine2 = transform * transform_attribute; + draw_raster_outline(scene, &outline_transform, render_params); + + if layer { + scene.pop_layer(); + } + + return; + } + + let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); + + let image_brush = peniko::ImageBrush::new(peniko::ImageData { + data: image.to_flat_u8().0.into(), + format: peniko::ImageFormat::Rgba8, + width: image.width, + height: image.height, + alpha_type: peniko::ImageAlphaType::Alpha, + }) + .with_extend(peniko::Extend::Repeat); + + scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); + + if layer { + scene.pop_layer(); + } +} + +/// The metadata a raster contributes under an `element_id`: a unit-square click target, +/// plus the first item's transform and any merged-layers snapshot when a first item exists. +fn collect_raster_metadata(first_row: Option>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option) { + let Some(element_id) = element_id else { return }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); + + metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); + metadata.upstream_footprints.insert(element_id, footprint); + // TODO: Find a way to handle more than one item of the `List>` + if let Some(item) = first_row { + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + metadata.local_transforms.insert(element_id, transform); + + // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, + // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can + // surface the original child layers' click targets (the same mechanism Boolean Operation uses). + // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization + // area, so the children are already in the coordinate space matching `footprint` here, meaning we must NOT + // multiply in `transform` (which is the rasterization area, not a layer-stack transform). + let upstream_nested_layers = item.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS); + if !upstream_nested_layers.is_empty() { + upstream_nested_layers.collect_metadata(metadata, footprint, None, None); + } + } +} + +/// Adds the unit-square click target every raster item presents, placed by the item's transform. +fn add_unit_square_click_target(transform: DAffine2, click_targets: &mut Vec) { + // The unit square is the raster's own space, so its placement only exists in the item transform + let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); + subpath.apply_transform(transform); + + click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); +} + impl Render for List> { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { - let Some(image) = self.element(index) else { continue }; - - let 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.); - - if image.data.is_empty() { - continue; - } - - if render_params.to_canvas() { - let mut image_copy = image.clone(); - image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha()); - let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid); - - render.parent_tag( - "foreignObject", - |attributes| { - let size = DVec2::new(image.width as f64, image.height as f64); - - let matrix = transform * DAffine2::from_scale(1. / size); - let matrix = format_transform_matrix(matrix); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - - attributes.push("width", size.x.to_string()); - attributes.push("height", size.y.to_string()); - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }, - |render| { - render.leaf_tag( - "img", // Must be a self-closing (void element) tag, so we can't use `div` or `span`, for example - |attributes| { - attributes.push("data-canvas-placeholder", id.to_string()); - }, - ) - }, - ); - } else { - let base64_string = image.base64_string.clone().unwrap_or_else(|| { - use base64::Engine; - - let output = image.to_png(); - let preamble = "data:image/png;base64,"; - let mut base64_string = String::with_capacity(preamble.len() + output.len() * 4); - base64_string.push_str(preamble); - base64::engine::general_purpose::STANDARD.encode_string(output, &mut base64_string); - base64_string - }); - - render.leaf_tag("image", |attributes| { - attributes.push("width", "1"); - attributes.push("height", "1"); - attributes.push("preserveAspectRatio", "none"); - attributes.push("href", base64_string); - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push(ATTR_TRANSFORM, matrix); - } - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }); - } + render_raster_cpu_item_svg(ItemRef::ListItem(self, index), render, render_params); } } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) { for index in 0..self.len() { - let Some(image) = self.element(index) else { continue }; - if image.data.is_empty() { - continue; - } - - 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.); - let blend_mode = blend_mode_attr.to_peniko(); - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let mut layer = false; - - if (opacity < 1. || (render_params.render_mode != RenderMode::Outline && blend_mode_attr != BlendMode::default())) - && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, false) - { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); - layer = true; - } - - let transform_attribute: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - if let RenderMode::Outline = render_params.render_mode { - let outline_transform: DAffine2 = transform * transform_attribute; - draw_raster_outline(scene, &outline_transform, render_params); - - if layer { - scene.pop_layer(); - } - - continue; - } - - let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(image.width as f64, image.height as f64)); - - let image_brush = peniko::ImageBrush::new(peniko::ImageData { - data: image.to_flat_u8().0.into(), - format: peniko::ImageFormat::Rgba8, - width: image.width, - height: image.height, - alpha_type: peniko::ImageAlphaType::Alpha, - }) - .with_extend(peniko::Extend::Repeat); - - scene.draw_image(&image_brush, kurbo::Affine::new(image_transform.to_cols_array())); - - if layer { - scene.pop_layer(); - } + render_raster_cpu_item_to_vello(ItemRef::ListItem(self, index), scene, transform, render_params); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option, _inherited_appearance: Option<&Appearance>) { - let Some(element_id) = element_id else { return }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); - metadata.upstream_footprints.insert(element_id, footprint); - // TODO: Find a way to handle more than one item of the `List>` - if !self.is_empty() { - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - metadata.local_transforms.insert(element_id, transform); - - // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, - // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can - // surface the original child layers' click targets (the same mechanism Boolean Operation uses). - // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization - // area, so the children are already in the coordinate space matching `footprint` here — we must NOT - // multiply in `transform` (which is the rasterization area, not a layer-stack transform). - let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, 0); - if !upstream_nested_layers.is_empty() { - upstream_nested_layers.collect_metadata(metadata, footprint, None, None); - } - } + collect_raster_metadata((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), metadata, footprint, element_id); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - // The unit square is the raster's own space, so its placement only exists in the item transform - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - subpath.apply_transform(transform); - - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + add_unit_square_click_target(self.attribute_cloned_or_default(ATTR_TRANSFORM, index), click_targets); } } } @@ -2152,97 +2419,82 @@ impl Render for List> { fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { for index in 0..self.len() { - let Some(raster) = self.element(index) else { continue }; - 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.); - let clip_attr: bool = self.attribute_cloned_or_default(ATTR_CLIPPING_MASK, index); - let blend_mode = match render_params.render_mode { - RenderMode::Outline => peniko::Mix::Normal, - _ => blend_mode_attr.to_peniko(), - }; - - let mut layer = false; - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr; - if (render_params.render_mode != RenderMode::Outline && any_nondefault) - && let RenderBoundingBox::Rectangle(bounds) = self.bounding_box(transform, true) - { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); - layer = true; - } - - let transform_attribute: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - if let RenderMode::Outline = render_params.render_mode { - let outline_transform = transform * transform_attribute; - draw_raster_outline(scene, &outline_transform, render_params); - - if layer { - scene.pop_layer(); - } - - continue; - } - - let width = raster.data().width(); - let height = raster.data().height(); - let image = peniko::ImageBrush::new(peniko::ImageData { - data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()), - format: peniko::ImageFormat::Rgba8, - width, - height, - alpha_type: peniko::ImageAlphaType::Alpha, - }) - .with_extend(peniko::Extend::Repeat); - let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64)); - scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array())); - context.resource_overrides.push((image, raster.texture.clone())); - - if layer { - scene.pop_layer() - } + render_raster_gpu_item_to_vello(ItemRef::ListItem(self, index), scene, transform, context, render_params); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option, _inherited_appearance: Option<&Appearance>) { - let Some(element_id) = element_id else { return }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - - metadata.click_targets.insert(element_id, vec![ClickTarget::new_with_subpath(subpath, 0.).into()]); - metadata.upstream_footprints.insert(element_id, footprint); - // TODO: Find a way to handle more than one item of the `List>` - if !self.is_empty() { - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - metadata.local_transforms.insert(element_id, transform); - - // If this raster carries a snapshot of upstream graphic content (e.g. it was produced by Rasterize, - // which destructively merges its inputs into pixels), recurse into that snapshot so the editor can - // surface the original child layers' click targets (the same mechanism Boolean Operation uses). - // The snapshot was captured before Rasterize shifted its input transforms to align with the rasterization - // area, so the children are already in the coordinate space matching `footprint` here — we must NOT - // multiply in `transform` (which is the rasterization area, not a layer-stack transform). - let upstream_nested_layers = self.attribute_cloned_or_default::>(ATTR_EDITOR_MERGED_LAYERS, 0); - if !upstream_nested_layers.is_empty() { - upstream_nested_layers.collect_metadata(metadata, footprint, None, None); - } - } + collect_raster_metadata((!self.is_empty()).then_some(ItemRef::ListItem(self, 0)), metadata, footprint, element_id); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); - subpath.apply_transform(transform); - - click_targets.push(ClickTarget::new_with_subpath(subpath, 0.)); + add_unit_square_click_target(self.attribute_cloned_or_default(ATTR_TRANSFORM, index), click_targets); } } } +/// Draws one item of GPU raster content into the Vello scene as a placeholder image, registering the texture override. +fn render_raster_gpu_item_to_vello(item: ItemRef<'_, Raster>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { + let Some(raster) = item.element() else { return }; + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let clip_attr: bool = item.attribute_cloned_or_default(ATTR_CLIPPING_MASK); + let blend_mode = match render_params.render_mode { + RenderMode::Outline => peniko::Mix::Normal, + _ => blend_mode_attr.to_peniko(), + }; + + let mut layer = false; + + let whole_bounds = || match item { + ItemRef::ListItem(list, _) => list.bounding_box(transform, true), + ItemRef::Item(item) => item.bounding_box(transform, true), + }; + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + let any_nondefault = blend_mode_attr != BlendMode::default() || opacity < 1. || clip_attr; + if (render_params.render_mode != RenderMode::Outline && any_nondefault) + && let RenderBoundingBox::Rectangle(bounds) = whole_bounds() + { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + let rect = kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &rect); + layer = true; + } + + let transform_attribute: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + if let RenderMode::Outline = render_params.render_mode { + let outline_transform = transform * transform_attribute; + draw_raster_outline(scene, &outline_transform, render_params); + + if layer { + scene.pop_layer(); + } + + return; + } + + let width = raster.data().width(); + let height = raster.data().height(); + let image = peniko::ImageBrush::new(peniko::ImageData { + data: peniko::Blob::new(LAZY_ARC_VEC_ZERO_U8.deref().clone()), + format: peniko::ImageFormat::Rgba8, + width, + height, + alpha_type: peniko::ImageAlphaType::Alpha, + }) + .with_extend(peniko::Extend::Repeat); + let image_transform = transform * transform_attribute * DAffine2::from_scale(1. / DVec2::new(width as f64, height as f64)); + scene.draw_image(&image, kurbo::Affine::new(image_transform.to_cols_array())); + context.resource_overrides.push((image, raster.texture.clone())); + + if layer { + scene.pop_layer() + } +} + // Since colors and gradients are technically infinitely big, we have to implement // workarounds for rendering them correctly in a way which still allows us // to cache the intermediate render data (SVG string/Vello scene). @@ -2251,64 +2503,76 @@ impl Render for List> { // later replace with the current viewport transform before each render. impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - for (index, color) in self.iter_element_values().enumerate() { - let blend_mode: 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.); - render.leaf_tag("polyline", |attributes| { - // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses - // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. - // 1e7 stays under that limit while still being far larger than any practical document extent. - const MAX: f64 = 1e7; - attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); - - attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex())); - if color.a() < 1. { - attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string()); - } - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - }); + for index in 0..self.len() { + render_color_item_svg(ItemRef::ListItem(self, index), render, render_params); } } fn render_to_vello(&self, scene: &mut Scene, _parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - use vello::peniko; - - for (index, color) in self.iter_element_values().enumerate() { - 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.); - let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let vello_color = SRGBA8::from(*color).to_peniko_color(); - - let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); - - let mut layer = false; - if opacity < 1. || blend_mode_attr != BlendMode::default() { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); - layer = true; - } - - scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect); - - if layer { - scene.pop_layer(); - } + for index in 0..self.len() { + render_color_item_to_vello(ItemRef::ListItem(self, index), scene, render_params); } } } +/// Emits one item of color content as SVG, painting a stand-in for an infinite background. +fn render_color_item_svg(item: ItemRef<'_, Color>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(color) = item.element() else { return }; + let blend_mode: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + render.leaf_tag("polyline", |attributes| { + // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses + // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. + // 1e7 stays under that limit while still being far larger than any practical document extent. + const MAX: f64 = 1e7; + attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); + + attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex())); + if color.a() < 1. { + attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string()); + } + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }); +} + +/// Draws one item of color content into the Vello scene under the viewport-replaced infinite transform. +fn render_color_item_to_vello(item: ItemRef<'_, Color>, scene: &mut Scene, render_params: &RenderParams) { + use vello::peniko; + + let Some(color) = item.element() else { return }; + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let blend_mode = blend_mode_attr.to_peniko(); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let vello_color = SRGBA8::from(*color).to_peniko_color(); + + let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); + + let mut layer = false; + if opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); + layer = true; + } + + scene.fill(peniko::Fill::NonZero, kurbo::Affine::scale(f64::INFINITY), vello_color, None, &rect); + + if layer { + scene.pop_layer(); + } +} + /// A gradient's control geometry in its local space: the unit circle a radial gradient's transform carries to its drawn ellipse, or the (0,0) to (1,0) gradient line for a linear one. fn gradient_control_outline(gradient_form: GradientForm) -> Subpath { match gradient_form { @@ -2322,237 +2586,273 @@ fn gradient_control_interior_is_clickable(gradient_form: GradientForm) -> bool { gradient_form == GradientForm::Radial } +/// For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. +/// The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million. +fn gradient_thumbnail_rect(render_params: &RenderParams) -> Option<(DVec2, DVec2)> { + if render_params.thumbnail { + let truncated_size = render_params.footprint.resolution.as_dvec2(); + let margin = DVec2::ONE; + Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin)) + } else { + None + } +} + +/// Emits one item of gradient content as SVG. +fn render_gradient_item_svg(item: ItemRef<'_, Gradient>, render: &mut SvgRender, render_params: &RenderParams) { + render_gradient_item_svg_with_thumbnail_rect(item, gradient_thumbnail_rect(render_params), render, render_params); +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - // For thumbnails the gradient fills a finite rect at the footprint's document space bounds, with a 1-unit margin to cover the `as u32` truncation of `Footprint::resolution`. - // The viewBox crops the overshoot. Canvas rendering keeps the polyline path since Chrome rejects rects larger than ~20 million. - let thumbnail_rect = if render_params.thumbnail { - let truncated_size = render_params.footprint.resolution.as_dvec2(); - let margin = DVec2::ONE; - Some((render_params.footprint.transform.translation - margin / 2., truncated_size + margin)) - } else { - None - }; + let thumbnail_rect = gradient_thumbnail_rect(render_params); for index in 0..self.len() { - let Some(gradient) = self.element(index) else { continue }; - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let blend_mode: 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.); - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let settings = GradientSettings::from_list_row_attributes(self, index); - let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; - render.leaf_tag(tag, |attributes| { - if let Some((min, size)) = thumbnail_rect { - attributes.push("x", min.x.to_string()); - attributes.push("y", min.y.to_string()); - attributes.push("width", size.x.to_string()); - attributes.push("height", size.y.to_string()); - } else { - // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses - // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. - // 1e7 stays under that limit while still being far larger than any practical document extent. - const MAX: f64 = 1e7; - attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); - } - - let (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); - - let mut stop_string = String::new(); - for (position, color, original_midpoint) in samples { - let _ = write!(stop_string, r##""); - } - - // render_thumbnail already added the footprint transform - let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; - let gradient_transform_matrix = format_transform_matrix(gradient_transform); - let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { - String::new() - } else { - format!(r#" gradientTransform="{gradient_transform_matrix}""#) - }; - - let gradient_id = generate_uuid(); - let gradient_spread_attribute = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { - String::new() - } else { - format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) - }; - - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied - match gradient_form { - GradientForm::Linear => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - GradientForm::Radial => { - let _ = write!( - &mut attributes.0.svg_defs, - r#"{stop_string}"# - ); - } - } - - attributes.push("fill", format!("url('#{gradient_id}')")); - - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - - if blend_mode != BlendMode::default() { - attributes.push("style", blend_mode.render()); - } - }); + render_gradient_item_svg_with_thumbnail_rect(ItemRef::ListItem(self, index), thumbnail_rect, render, render_params); } } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { - use vello::peniko; - - if let RenderMode::Outline = render_params.render_mode { - return; - } - - for ((index, gradient), gradient_form) in self.iter_element_values().enumerate().zip(self.iter_attribute_values_or_default::(ATTR_GRADIENT_FORM)) { - let 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.); - let gradient_transform = parent_transform * transform; - - let blend_mode = blend_mode_attr.to_peniko(); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let settings = GradientSettings::from_list_row_attributes(self, index); - let (samples, span) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); - - let stops = peniko_color_stops(&samples); - - let extend = peniko_extend(settings.spread); - - // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. - // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. - let kind = match gradient_form { - GradientForm::Linear => peniko::LinearGradientPosition { - start: to_point(DVec2::X * span.0), - end: to_point(DVec2::X * span.1), - } - .into(), - GradientForm::Radial => peniko::RadialGradientPosition { - start_center: to_point(DVec2::ZERO), - start_radius: 0., - end_center: to_point(DVec2::ZERO), - end_radius: span.1 as f32, - } - .into(), - }; - - let fill = peniko::Brush::Gradient(peniko::Gradient { - kind, - stops, - extend, - interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, - ..Default::default() - }); - let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array()); - let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); - - let mut layer = false; - if opacity < 1. || blend_mode_attr != BlendMode::default() { - let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); - // See implementation in `List` for more detail - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); - layer = true; - } - - // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform - scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); - scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); - scene.encoding_mut().encode_shape(&rect, true); - - scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); - scene.encoding_mut().swap_last_path_tags(); - scene.encoding_mut().encode_brush(&fill, 1.); - - if layer { - scene.pop_layer(); - } + for index in 0..self.len() { + render_gradient_item_to_vello(ItemRef::ListItem(self, index), scene, parent_transform, render_params); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option, _inherited_appearance: Option<&Appearance>) { - let Some(element_id) = element_id else { return }; - if self.is_empty() { - return; - } - - // Targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]` - let item_zero_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); - let item_zero_inverse = if transform_is_invertible(item_zero_transform) { - item_zero_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let mut outline_targets = Vec::new(); - let mut click_targets = Vec::new(); - for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(item_zero_inverse * item_transform); - let target = Arc::new(target); - - if gradient_control_interior_is_clickable(gradient_form) { - click_targets.push(target.clone()); - } - outline_targets.push(target); - } - - metadata.outlines.insert(element_id, outline_targets); - if !click_targets.is_empty() { - metadata.click_targets.insert(element_id, click_targets); - } + collect_gradient_items_metadata((0..self.len()).map(|index| ItemRef::ListItem(self, index)), metadata, element_id); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - if !gradient_control_interior_is_clickable(gradient_form) { - continue; - } - - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(transform); - click_targets.push(target); + add_gradient_item_click_targets(ItemRef::ListItem(self, index), click_targets); } } fn add_upstream_outline_targets(&self, outlines: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - - let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); - target.apply_transform(transform); - outlines.push(target); + add_gradient_item_outline_targets(ItemRef::ListItem(self, index), outlines); } } } +/// Emits one item of gradient content as SVG, painting the thumbnail rect or an infinite-background stand-in. +fn render_gradient_item_svg_with_thumbnail_rect(item: ItemRef<'_, Gradient>, thumbnail_rect: Option<(DVec2, DVec2)>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(gradient) = item.element() else { return }; + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let settings = gradient_settings_from_item(item); + let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; + render.leaf_tag(tag, |attributes| { + if let Some((min, size)) = thumbnail_rect { + attributes.push("x", min.x.to_string()); + attributes.push("y", min.y.to_string()); + attributes.push("width", size.x.to_string()); + attributes.push("height", size.y.to_string()); + } else { + // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses + // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. + // 1e7 stays under that limit while still being far larger than any practical document extent. + const MAX: f64 = 1e7; + attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); + } + + let (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder); + + let mut stop_string = String::new(); + for (position, color, original_midpoint) in samples { + let _ = write!(stop_string, r##""); + } + + // render_thumbnail already added the footprint transform + let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; + let gradient_transform_matrix = format_transform_matrix(gradient_transform); + let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { + String::new() + } else { + format!(r#" gradientTransform="{gradient_transform_matrix}""#) + }; + + let gradient_id = generate_uuid(); + let gradient_spread_attribute = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) { + String::new() + } else { + format!(r#" spreadMethod="{}""#, settings.spread.svg_name()) + }; + + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied + match gradient_form { + GradientForm::Linear => { + let _ = write!( + &mut attributes.0.svg_defs, + r#"{stop_string}"# + ); + } + GradientForm::Radial => { + let _ = write!( + &mut attributes.0.svg_defs, + r#"{stop_string}"# + ); + } + } + + attributes.push("fill", format!("url('#{gradient_id}')")); + + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + + if blend_mode != BlendMode::default() { + attributes.push("style", blend_mode.render()); + } + }); +} + +/// Draws one item of gradient content into the Vello scene under the viewport-replaced infinite transform. +fn render_gradient_item_to_vello(item: ItemRef<'_, Gradient>, scene: &mut Scene, parent_transform: DAffine2, render_params: &RenderParams) { + use vello::peniko; + + if let RenderMode::Outline = render_params.render_mode { + return; + } + + { + let Some(gradient) = item.element() else { return }; + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let gradient_transform = parent_transform * transform; + + let blend_mode = blend_mode_attr.to_peniko(); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let settings = gradient_settings_from_item(item); + let (samples, span) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::VelloRampTexels); + + let stops = peniko_color_stops(&samples); + + let extend = peniko_extend(settings.spread); + + // The unit gradient line is the +X unit vector in local space, before the item's transform is applied. + // For radial, the unit-radius circle at the origin scales out to the line's length once the brush transform applies. + let kind = match gradient_form { + GradientForm::Linear => peniko::LinearGradientPosition { + start: to_point(DVec2::X * span.0), + end: to_point(DVec2::X * span.1), + } + .into(), + GradientForm::Radial => peniko::RadialGradientPosition { + start_center: to_point(DVec2::ZERO), + start_radius: 0., + end_center: to_point(DVec2::ZERO), + end_radius: span.1 as f32, + } + .into(), + }; + + let fill = peniko::Brush::Gradient(peniko::Gradient { + kind, + stops, + extend, + interpolation_alpha_space: peniko::InterpolationAlphaSpace::Unpremultiplied, + ..Default::default() + }); + let brush_transform = kurbo::Affine::new(gradient_placement(gradient_transform, gradient_form).to_cols_array()); + let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.)); + + let mut layer = false; + if opacity < 1. || blend_mode_attr != BlendMode::default() { + let blending = peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver); + // See implementation in `List` for more detail + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::scale(f64::INFINITY), &rect); + layer = true; + } + + // Encode shape and brush manually instead of Scene.fill(), which would multiply brush_transform by the path transform + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&kurbo::Affine::scale(f64::INFINITY))); + scene.encoding_mut().encode_fill_style(peniko::Fill::NonZero); + scene.encoding_mut().encode_shape(&rect, true); + + scene.encoding_mut().encode_transform(vello_encoding::Transform::from_kurbo(&brush_transform)); + scene.encoding_mut().swap_last_path_tags(); + scene.encoding_mut().encode_brush(&fill, 1.); + + if layer { + scene.pop_layer(); + } + } +} + +/// The metadata pass over a run of gradient items: each contributes its control geometry as targets under the +/// run's `element_id`, baked relative to the first item's transform (recorded as its `local_transforms` entry). +fn collect_gradient_items_metadata<'a>(items: impl Iterator>, metadata: &mut RenderMetadata, element_id: Option) { + let Some(element_id) = element_id else { return }; + + let mut item_zero_inverse = None; + let mut outline_targets = Vec::new(); + let mut click_targets = Vec::new(); + for item in items { + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + // The first item's transform is the reference all targets bake against + let item_zero_inverse = *item_zero_inverse.get_or_insert_with(|| if transform_is_invertible(item_transform) { item_transform.inverse() } else { DAffine2::IDENTITY }); + + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(item_zero_inverse * item_transform); + let target = Arc::new(target); + + if gradient_control_interior_is_clickable(gradient_form) { + click_targets.push(target.clone()); + } + outline_targets.push(target); + } + + if outline_targets.is_empty() { + return; + } + + metadata.outlines.insert(element_id, outline_targets); + if !click_targets.is_empty() { + metadata.click_targets.insert(element_id, click_targets); + } +} + +/// Collects one gradient item's control geometry as a click target when its interior is draggable. +fn add_gradient_item_click_targets(item: ItemRef<'_, Gradient>, click_targets: &mut Vec) { + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + if !gradient_control_interior_is_clickable(gradient_form) { + return; + } + + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(transform); + click_targets.push(target); +} + +/// Collects one gradient item's control geometry as an outline target. +fn add_gradient_item_outline_targets(item: ItemRef<'_, Gradient>, outlines: &mut Vec) { + let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM); + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + + let mut target = ClickTarget::new_with_subpath(gradient_control_outline(gradient_form), 0.); + target.apply_transform(transform); + outlines.push(target); +} + /// Builds a `kurbo::BezPath` from a glyph outline, baking in the glyph origin (`ox`, `oy`) and faux-italic shear (`tilt_tan`). struct GlyphOutlinePen<'a> { path: &'a mut BezPath, @@ -2624,22 +2924,22 @@ fn draw_glyph_run_to_bezpaths(glyph_run: &parley::GlyphRun<'_, ()>, x_offset: f3 } } -/// Lays out text item `index` of a styled `List` and returns its local size and transform. The `BoundingBox` trait can't do +/// Lays out one text item and returns its local size and transform. The `BoundingBox` trait can't do /// this since a bare `String` carries no typography, so click-target and bounding-box computation share this. Falls back to an em /// square if the font isn't registered yet. -fn text_item_size_and_transform(list: &List, index: usize) -> Option<(DVec2, DAffine2)> { - let text = list.element(index)?; +fn text_item_size_and_transform(item: ItemRef<'_, String>) -> Option<(DVec2, DAffine2)> { + let text = item.element()?; let font: Resource = { - let f: Resource = list.attribute_cloned_or_default(ATTR_FONT, index); + let f: Resource = item.attribute_cloned_or_default(ATTR_FONT); if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } }; - let font_size: f64 = list.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); - let line_height: f64 = list.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); - let letter_spacing: f64 = list.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); - let max_width: Option = list.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); - let max_height: Option = list.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); - let align: text_nodes::TextAlign = list.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); - let transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let font_size: f64 = item.attribute_cloned_or(ATTR_FONT_SIZE, DEFAULT_FONT_SIZE); + let line_height: f64 = item.attribute_cloned_or(ATTR_LINE_HEIGHT, 1.2); + let letter_spacing: f64 = item.attribute_cloned_or(ATTR_LETTER_SPACING, 0.); + let max_width: Option = item.attribute_cloned_or(ATTR_MAX_WIDTH, None); + let max_height: Option = item.attribute_cloned_or(ATTR_MAX_HEIGHT, None); + let align: text_nodes::TextAlign = item.attribute_cloned_or_default(ATTR_TEXT_ALIGN); + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); let typesetting = text_nodes::TypesettingConfig { font_size, @@ -2668,15 +2968,7 @@ fn text_item_size_and_transform(list: &List, index: usize) -> Option<(DV pub fn text_list_bounding_box(list: &List, outer_transform: DAffine2) -> RenderBoundingBox { let mut bounds: Option<[DVec2; 2]> = None; for index in 0..list.len() { - let Some((size, transform)) = text_item_size_and_transform(list, index) else { continue }; - let full_transform = outer_transform * transform; - for corner in [DVec2::ZERO, DVec2::new(size.x, 0.), DVec2::new(0., size.y), size] { - let point = full_transform.transform_point2(corner); - bounds = Some(match bounds { - Some([min, max]) => [min.min(point), max.max(point)], - None => [point, point], - }); - } + accumulate_text_item_bounds(ItemRef::ListItem(list, index), outer_transform, &mut bounds); } match bounds { Some(bounds) => RenderBoundingBox::Rectangle(bounds), @@ -2684,6 +2976,19 @@ pub fn text_list_bounding_box(list: &List, outer_transform: DAffine2) -> } } +/// Folds one laid-out text item's corner points into the running bounds. +fn accumulate_text_item_bounds(item: ItemRef<'_, String>, outer_transform: DAffine2, bounds: &mut Option<[DVec2; 2]>) { + let Some((size, transform)) = text_item_size_and_transform(item) else { return }; + let full_transform = outer_transform * transform; + for corner in [DVec2::ZERO, DVec2::new(size.x, 0.), DVec2::new(0., size.y), size] { + let point = full_transform.transform_point2(corner); + *bounds = Some(match *bounds { + Some([min, max]) => [min.min(point), max.max(point)], + None => [point, point], + }); + } +} + /// Like `List::thumbnail_bounding_box`, but lays out `Graphic::TextList` items, which the `BoundingBox` trait reports as `None`. /// Used for layer thumbnails so text layers (whose content is a `List` wrapping the text) frame their content. pub fn graphic_list_bounding_box(list: &List, transform: DAffine2) -> RenderBoundingBox { @@ -2693,12 +2998,7 @@ pub fn graphic_list_bounding_box(list: &List, transform: DAffine2) -> R for index in 0..list.len() { let item_transform = transform * list.attribute_cloned_or_default::(ATTR_TRANSFORM, index); let Some(graphic) = list.element(index) else { continue }; - let bounds = match graphic { - Graphic::TextList(text_list) => text_list_bounding_box(text_list, item_transform), - Graphic::GraphicList(sub_list) => graphic_list_bounding_box(sub_list, item_transform), - other => other.thumbnail_bounding_box(item_transform, true), - }; - match bounds { + match graphic_thumbnail_bounding_box(graphic, item_transform) { RenderBoundingBox::None => {} RenderBoundingBox::Infinite => any_infinite = true, RenderBoundingBox::Rectangle([min, max]) => { @@ -2717,208 +3017,247 @@ pub fn graphic_list_bounding_box(list: &List, transform: DAffine2) -> R } } +/// One graphic's thumbnail bounds, laying out text (which the `BoundingBox` trait reports as `None`) and recursing into groups. +fn graphic_thumbnail_bounding_box(graphic: &Graphic, item_transform: DAffine2) -> RenderBoundingBox { + match graphic { + Graphic::Text(item) => { + let mut bounds = None; + accumulate_text_item_bounds(ItemRef::Item(item), item_transform, &mut bounds); + match bounds { + Some(bounds) => RenderBoundingBox::Rectangle(bounds), + None => RenderBoundingBox::None, + } + } + Graphic::TextList(text_list) => text_list_bounding_box(text_list, item_transform), + // A lone graphic recurses like a one-item group, composing its envelope transform + Graphic::Graphic(item) => { + let inner_transform = item_transform * item.attribute_cloned_or_default::(ATTR_TRANSFORM); + graphic_thumbnail_bounding_box(item.element(), inner_transform) + } + Graphic::GraphicList(sub_list) => graphic_list_bounding_box(sub_list, item_transform), + other => other.thumbnail_bounding_box(item_transform, true), + } +} + +/// Emits one item of text content as SVG, laying out its glyphs and wrapping them in a styled group. +fn render_text_item_svg(item: ItemRef<'_, String>, render: &mut SvgRender, render_params: &RenderParams) { + let Some(text) = item.element() else { return }; + if text.is_empty() { + return; + } + + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let font: Resource = { + let f: Resource = item.attribute_cloned_or_default(ATTR_FONT); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } + }; + let font_size: f64 = item.attribute_cloned_or(ATTR_FONT_SIZE, DEFAULT_FONT_SIZE); + let line_height: f64 = item.attribute_cloned_or(ATTR_LINE_HEIGHT, 1.2); + let letter_spacing: f64 = item.attribute_cloned_or(ATTR_LETTER_SPACING, 0.); + let max_width: Option = item.attribute_cloned_or(ATTR_MAX_WIDTH, None); + let max_height: Option = item.attribute_cloned_or(ATTR_MAX_HEIGHT, None); + let letter_tilt: f64 = item.attribute_cloned_or(ATTR_LETTER_TILT, 0.); + let align: text_nodes::TextAlign = item.attribute_cloned_or_default(ATTR_TEXT_ALIGN); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + letter_spacing, + letter_tilt, + max_width, + max_height, + align, + }; + + let mut glyph_paths: Vec = Vec::new(); + + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; + let tilt_tan = letter_tilt.to_radians().tan(); + + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { + draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { + glyph_paths.push(bez_path.to_svg()); + }); + }); + }); + + if glyph_paths.is_empty() { + return; + } + + // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. + render.parent_tag( + "g", + |attributes| { + let matrix = format_transform_matrix(transform); + if !matrix.is_empty() { + attributes.push("transform", matrix); + } + if opacity < 1. { + attributes.push("opacity", opacity.to_string()); + } + if blend_mode_attr != BlendMode::default() { + attributes.push("style", blend_mode_attr.render()); + } + }, + |render| { + for path_d in glyph_paths { + render.leaf_tag("path", |attributes| { + attributes.push("d", path_d); + if let RenderMode::Outline = render_params.render_mode { + attributes.push("fill", "none"); + attributes.push("stroke", "black"); + attributes.push("stroke-width", "1"); + } else { + attributes.push("fill", "black"); + attributes.push("fill-rule", "nonzero"); + } + }); + } + }, + ); +} + +/// Draws one item of text content into the Vello scene, laying out its glyphs under the item's styling. +fn render_text_item_to_vello(item: ItemRef<'_, String>, scene: &mut Scene, transform: DAffine2, render_params: &RenderParams) { + let Some(text) = item.element() else { return }; + if text.is_empty() { + return; + } + + let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + let font: Resource = { + let f: Resource = item.attribute_cloned_or_default(ATTR_FONT); + if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } + }; + let font_size: f64 = item.attribute_cloned_or(ATTR_FONT_SIZE, DEFAULT_FONT_SIZE); + let line_height: f64 = item.attribute_cloned_or(ATTR_LINE_HEIGHT, 1.2); + let letter_spacing: f64 = item.attribute_cloned_or(ATTR_LETTER_SPACING, 0.); + let max_width: Option = item.attribute_cloned_or(ATTR_MAX_WIDTH, None); + let max_height: Option = item.attribute_cloned_or(ATTR_MAX_HEIGHT, None); + let letter_tilt: f64 = item.attribute_cloned_or(ATTR_LETTER_TILT, 0.); + let align: text_nodes::TextAlign = item.attribute_cloned_or_default(ATTR_TEXT_ALIGN); + let blend_mode_attr: BlendMode = item.attribute_cloned_or_default(ATTR_BLEND_MODE); + let opacity_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.); + let opacity_fill_attr: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.); + let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; + + let typesetting = text_nodes::TypesettingConfig { + font_size, + line_height_ratio: line_height, + letter_spacing, + letter_tilt, + max_width, + max_height, + align, + }; + + let affine = Affine::new((transform * item_transform).to_cols_array()); + + text_nodes::TextContext::with_thread_local(|ctx| { + let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; + + let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); + if needs_layer { + let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); + let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); + let padding = font_size; + let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding); + let transformed_bounds = affine.transform_rect_bbox(bounds); + scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); + } + + let tilt_tan = letter_tilt.to_radians().tan(); + + text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { + draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { + if let RenderMode::Outline = render_params.render_mode { + let (outline_stroke, outline_color) = get_outline_styles(render_params); + scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); + } else { + scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); + } + }); + }); + + if needs_layer { + scene.pop_layer(); + } + }); +} + +/// The metadata pass over a run of text items. Click targets are baked relative to the first item's transform, +/// which `Graphic::collect_metadata` records as `local_transforms[element_id]`. +fn collect_text_items_metadata<'a>(items: impl Iterator>, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { + let mut item_zero_transform = None; + let mut item_zero_inverse = DAffine2::IDENTITY; + + let mut accumulated_click_targets: HashMap>> = HashMap::new(); + + for item in items { + // The first item's transform is the reference all targets bake against + let item_zero_transform = *item_zero_transform.get_or_insert_with(|| { + let transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM); + item_zero_inverse = if transform.matrix2.determinant() != 0. { transform.inverse() } else { DAffine2::IDENTITY }; + transform + }); + + let Some(element_id) = caller_element_id.or(item.layer()) else { continue }; + + // When recovering element_id from the item's tag (caller passed None), also store the transform metadata. + if caller_element_id.is_none() { + metadata.upstream_footprints.entry(element_id).or_insert(footprint); + metadata.local_transforms.entry(element_id).or_insert(item_zero_transform); + } + + let Some((size, item_transform)) = text_item_size_and_transform(item) else { continue }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, size); + let mut target = ClickTarget::new_with_subpath(subpath, 0.); + target.apply_transform(item_zero_inverse * item_transform); + accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target)); + } + + // One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage). + for (element_id, targets) in accumulated_click_targets { + metadata.outlines.insert(element_id, targets.clone()); + metadata.click_targets.insert(element_id, targets); + } +} + +/// Collects one text item's laid-out rectangle as a click target. +fn add_text_item_click_targets(item: ItemRef<'_, String>, click_targets: &mut Vec) { + let Some((size, transform)) = text_item_size_and_transform(item) else { return }; + let subpath = Subpath::new_rectangle(DVec2::ZERO, size); + let mut target = ClickTarget::new_with_subpath(subpath, 0.); + target.apply_transform(transform); + click_targets.push(target); +} + impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..self.len() { - let Some(text) = self.element(index) else { continue }; - if text.is_empty() { - continue; - } - - let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 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.); - let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); - let font: Resource = { - let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index); - if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } - }; - let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); - let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); - let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); - let max_width: Option = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); - let max_height: Option = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); - let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.); - let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, index); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let typesetting = text_nodes::TypesettingConfig { - font_size, - line_height_ratio: line_height, - letter_spacing, - letter_tilt, - max_width, - max_height, - align, - }; - - let mut glyph_paths: Vec = Vec::new(); - - text_nodes::TextContext::with_thread_local(|ctx| { - let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; - let tilt_tan = letter_tilt.to_radians().tan(); - - text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { - glyph_paths.push(bez_path.to_svg()); - }); - }); - }); - - if glyph_paths.is_empty() { - continue; - } - - // Wrap all glyph elements in a with the item's transform/opacity/blend-mode. - render.parent_tag( - "g", - |attributes| { - let matrix = format_transform_matrix(transform); - if !matrix.is_empty() { - attributes.push("transform", matrix); - } - if opacity < 1. { - attributes.push("opacity", opacity.to_string()); - } - if blend_mode_attr != BlendMode::default() { - attributes.push("style", blend_mode_attr.render()); - } - }, - |render| { - for path_d in glyph_paths { - render.leaf_tag("path", |attributes| { - attributes.push("d", path_d); - if let RenderMode::Outline = render_params.render_mode { - attributes.push("fill", "none"); - attributes.push("stroke", "black"); - attributes.push("stroke-width", "1"); - } else { - attributes.push("fill", "black"); - attributes.push("fill-rule", "nonzero"); - } - }); - } - }, - ); + render_text_item_svg(ItemRef::ListItem(self, index), render, render_params); } } fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) { for index in 0..self.len() { - let Some(text) = self.element(index) else { continue }; - if text.is_empty() { - continue; - } - - let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); - let font: Resource = { - let f: Resource = self.attribute_cloned_or_default(ATTR_FONT, index); - if f.is_empty() { text_nodes::FALLBACK_FONT_RESOURCE.clone() } else { f } - }; - let font_size: f64 = self.attribute_cloned_or(ATTR_FONT_SIZE, index, DEFAULT_FONT_SIZE); - let line_height: f64 = self.attribute_cloned_or(ATTR_LINE_HEIGHT, index, 1.2); - let letter_spacing: f64 = self.attribute_cloned_or(ATTR_LETTER_SPACING, index, 0.); - let max_width: Option = self.attribute_cloned_or(ATTR_MAX_WIDTH, index, None); - let max_height: Option = self.attribute_cloned_or(ATTR_MAX_HEIGHT, index, None); - let letter_tilt: f64 = self.attribute_cloned_or(ATTR_LETTER_TILT, index, 0.); - let align: text_nodes::TextAlign = self.attribute_cloned_or_default(ATTR_TEXT_ALIGN, 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.); - let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32; - - let typesetting = text_nodes::TypesettingConfig { - font_size, - line_height_ratio: line_height, - letter_spacing, - letter_tilt, - max_width, - max_height, - align, - }; - - let affine = Affine::new((transform * item_transform).to_cols_array()); - - text_nodes::TextContext::with_thread_local(|ctx| { - let Some(layout) = ctx.layout_text(text, &font, typesetting) else { return }; - - let needs_layer = opacity < 1. || blend_mode_attr != BlendMode::default(); - if needs_layer { - let alignment_width = max_width.map(|w| w as f32).unwrap_or_else(|| layout.full_width()); - let blending = peniko::BlendMode::new(blend_mode_attr.to_peniko(), peniko::Compose::SrcOver); - let padding = font_size; - let bounds = kurbo::Rect::new(-padding, -padding, alignment_width as f64 + padding, layout.height() as f64 + padding); - let transformed_bounds = affine.transform_rect_bbox(bounds); - scene.push_layer(peniko::Fill::NonZero, blending, opacity, kurbo::Affine::IDENTITY, &transformed_bounds); - } - - let tilt_tan = letter_tilt.to_radians().tan(); - - text_nodes::for_each_styled_glyph_run(&layout, text, typesetting, |glyph_run, x_offset, space_extra| { - draw_glyph_run_to_bezpaths(glyph_run, x_offset, space_extra, tilt_tan, |bez_path| { - if let RenderMode::Outline = render_params.render_mode { - let (outline_stroke, outline_color) = get_outline_styles(render_params); - scene.stroke(&outline_stroke, affine, outline_color, None, bez_path); - } else { - scene.fill(peniko::Fill::NonZero, affine, peniko::Color::BLACK, None, bez_path); - } - }); - }); - - if needs_layer { - scene.pop_layer(); - } - }); + render_text_item_to_vello(ItemRef::ListItem(self, index), scene, transform, render_params); } } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option, _inherited_appearance: Option<&Appearance>) { - // Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`. - let item_zero_transform: DAffine2 = if !self.is_empty() { - self.attribute_cloned_or_default(ATTR_TRANSFORM, 0) - } else { - DAffine2::IDENTITY - }; - let item_zero_inverse = if item_zero_transform.matrix2.determinant() != 0. { - item_zero_transform.inverse() - } else { - DAffine2::IDENTITY - }; - - let mut accumulated_click_targets: HashMap>> = HashMap::new(); - - for index in 0..self.len() { - let layer_path: List = self.attribute_cloned_or_default::(ATTR_EDITOR_LAYER_PATH, index).0; - let layer = layer_path.iter_element_values().next_back().copied(); - let Some(element_id) = caller_element_id.or(layer) else { continue }; - - // When recovering element_id from the item's tag (caller passed None), also store the transform metadata. - if caller_element_id.is_none() { - metadata.upstream_footprints.entry(element_id).or_insert(footprint); - metadata.local_transforms.entry(element_id).or_insert(item_zero_transform); - } - - let Some((size, item_transform)) = text_item_size_and_transform(self, index) else { continue }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, size); - let mut target = ClickTarget::new_with_subpath(subpath, 0.); - target.apply_transform(item_zero_inverse * item_transform); - accumulated_click_targets.entry(element_id).or_default().push(Arc::new(target)); - } - - // One rectangle per text item, reused for the selection outline (there's no letterform geometry to outline at this stage). - for (element_id, targets) in accumulated_click_targets { - metadata.outlines.insert(element_id, targets.clone()); - metadata.click_targets.insert(element_id, targets); - } + collect_text_items_metadata((0..self.len()).map(|index| ItemRef::ListItem(self, index)), metadata, footprint, caller_element_id); } fn add_upstream_click_targets(&self, click_targets: &mut Vec, _inherited_appearance: Option<&Appearance>) { for index in 0..self.len() { - let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue }; - let subpath = Subpath::new_rectangle(DVec2::ZERO, size); - let mut target = ClickTarget::new_with_subpath(subpath, 0.); - target.apply_transform(transform); - click_targets.push(target); + add_text_item_click_targets(ItemRef::ListItem(self, index), click_targets); } } } diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index 109b7960ab..33d4f7ffa1 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -934,10 +934,10 @@ pub async fn wrap_graphic + 'n>( _: impl Ctx, #[implementations( List, - List, + List, List>, - List>, - List, + List>, + List, List, List, Item, @@ -981,6 +981,12 @@ pub async fn flatten_graphic(_: impl Ctx, content: List, fully_flatten: let recurse = fully_flatten || recursion_depth == 0; + // A boxed single graphic is the rank-0 spelling of the same nesting, so it flattens through the list path + let current_element = match current_element { + Graphic::Graphic(item) if recurse => Graphic::GraphicList(List::new_from_item(*item)), + element => element, + }; + match current_element { // If we're allowed to recurse, flatten any graphics we encounter Graphic::GraphicList(mut current_element) if recurse => { diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 1b83303a96..b408f458e7 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -223,8 +223,16 @@ fn flatten_vector(graphic_list: &List) -> List { item }; - match graphic.clone() { + // A boxed single graphic is the rank-0 version of the same nesting, so it flattens through the group path + let graphic = match graphic.clone() { + Graphic::Graphic(item) => Graphic::GraphicList(List::new_from_item(*item)), + other => other, + }; + + match graphic { + Graphic::Vector(item) => vec![compose_parent(*item)], Graphic::VectorList(vector) => vector.into_iter().map(compose_parent).collect::>(), + Graphic::Text(item) => text_nodes::shape_text_list(&List::new_from_item(item), false).into_iter().map(compose_parent).collect::>(), Graphic::TextList(text) => text_nodes::shape_text_list(&text, false).into_iter().map(compose_parent).collect::>(), Graphic::GraphicList(mut graphic) => { if parent_has_transform { @@ -261,7 +269,10 @@ fn flatten_vector(graphic_list: &List) -> List { } } // Rasters, colors, and gradients bound no region, so they contribute no operand - Graphic::None | Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) => Vec::new(), + Graphic::None(_) | Graphic::NoneList(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Color(_) | Graphic::Gradient(_) => Vec::new(), + Graphic::RasterCPUList(_) | Graphic::RasterGPUList(_) | Graphic::ColorList(_) | Graphic::GradientList(_) => Vec::new(), + // Normalized to GraphicList above + Graphic::Graphic(_) => Vec::new(), } }) .collect() diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 9bfb447d07..36bb15b337 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -47,14 +47,31 @@ trait VectorListIterMut { impl VectorListIterMut for List { fn for_each_vector_list_mut(&mut self, mut f: impl FnMut(&mut List)) { for graphic in self.iter_element_values_mut() { - if let Some(vector_list) = graphic.as_vector_list_mut() { - f(vector_list); - }; + match graphic { + // A lone vector is lifted into a one-item list for the duration of the call, so the shared per-list logic reaches it + Graphic::Vector(item) => { + let mut lifted = List::new_from_item(std::mem::take(&mut **item)); + f(&mut lifted); + if let Some(updated) = lifted.into_iter().next() { + **item = updated; + } + } + graphic => { + if let Some(vector_list) = graphic.as_vector_list_mut() { + f(vector_list); + } + } + } } } fn vector_count(&self) -> usize { - self.iter_element_values().filter_map(|element| element.as_vector_list()).map(|list| list.len()).sum() + self.iter_element_values() + .map(|element| match element { + Graphic::Vector(_) => 1, + element => element.as_vector_list().map_or(0, List::len), + }) + .sum() } } @@ -83,10 +100,18 @@ impl VectorItemMut for Item { impl VectorItemMut for Item { fn for_each_vector_mut(&mut self, mut f: impl FnMut(&mut Vector, DAffine2)) { - let Some(vector_list) = self.element_mut().as_vector_list_mut() else { return }; - let (elements, transforms) = vector_list.element_and_attribute_slices_mut::(ATTR_TRANSFORM); - for (vector, transform) in elements.iter_mut().zip(transforms.iter()) { - f(vector, *transform); + match self.element_mut() { + Graphic::Vector(item) => { + let transform = item.attribute_cloned_or_default::(ATTR_TRANSFORM); + f(item.element_mut(), transform); + } + element => { + let Some(vector_list) = element.as_vector_list_mut() else { return }; + let (elements, transforms) = vector_list.element_and_attribute_slices_mut::(ATTR_TRANSFORM); + for (vector, transform) in elements.iter_mut().zip(transforms.iter()) { + f(vector, *transform); + } + } } } } @@ -117,6 +142,8 @@ impl MapVectorItems for Graphic { // Collecting from zero items would drop the attribute columns, so an empty list is left alone Graphic::VectorList(list) if !list.is_empty() => *list = std::mem::take(list).into_iter().map(&mut *f).collect(), Graphic::GraphicList(list) => list.iter_element_values_mut().for_each(|nested| map_nested(nested, f)), + Graphic::Vector(item) => **item = f(std::mem::take(&mut **item)), + Graphic::Graphic(item) => map_nested(item.element_mut(), f), _ => {} } } @@ -132,6 +159,8 @@ impl MapVectorItems for Graphic { match graphic { Graphic::VectorList(list) => elements.extend(list.iter_element_values_mut()), Graphic::GraphicList(list) => list.iter_element_values_mut().for_each(|nested| collect(nested, elements)), + Graphic::Vector(item) => elements.push(item.element_mut()), + Graphic::Graphic(item) => collect(item.element_mut(), elements), _ => {} } } @@ -167,6 +196,12 @@ impl ExpandVectorItems for Graphic { *list = expanded; } Graphic::GraphicList(list) => list.iter_element_values_mut().for_each(|nested| expand_nested(nested, f)), + // One item expanding into many is a rank raise, so the leaf becomes the list it grew into + Graphic::Vector(item) => { + let expanded = f(std::mem::take(&mut **item)); + *graphic = Graphic::VectorList(expanded); + } + Graphic::Graphic(item) => expand_nested(item.element_mut(), f), _ => {} } } @@ -1510,6 +1545,12 @@ impl SolidifyStroke for Graphic { match graphic { Graphic::VectorList(list) if !list.is_empty() => *list = solidify_stroke_list_with_snapshot(std::mem::take(list)), Graphic::GraphicList(list) => list.iter_element_values_mut().for_each(solidify_nested), + // Solidifying can split one path into separate fill and stroke items, so the leaf becomes a list + Graphic::Vector(item) => { + let solidified = solidify_stroke_list_with_snapshot(List::new_from_item(std::mem::take(&mut **item))); + *graphic = Graphic::VectorList(solidified); + } + Graphic::Graphic(item) => solidify_nested(item.element_mut()), _ => {} } } @@ -2571,6 +2612,25 @@ async fn morph( build_transform_with_y_preservation(metadata_source_transform, start, end) } + /// The two paint kinds that can interpolate, read from either rank so the pairings below stay at four cases. + /// A gradient normalizes to the list form because the interpolation carries its placement attributes along. + enum InterpolablePaint<'a> { + Color(&'a Color), + Gradient(List), + } + + impl<'a> InterpolablePaint<'a> { + fn from_graphic(graphic: &'a Graphic) -> Option { + match graphic { + Graphic::Color(item) => Some(InterpolablePaint::Color(item.element())), + Graphic::ColorList(list) => list.element(0).map(InterpolablePaint::Color), + Graphic::Gradient(item) => Some(InterpolablePaint::Gradient(List::new_from_item(item.clone()))), + Graphic::GradientList(list) => list.element(0).is_some().then(|| InterpolablePaint::Gradient(list.clone())), + _ => None, + } + } + } + // Lerp between two graphics. Solid color and gradient pairings interpolate; all other pairings step at the midpoint. fn lerp_graphic(a: Option<&List>, b: Option<&List>, time: f64) -> Option> { let transparent = List::new_from_element(Color::TRANSPARENT).into_graphic_list(); @@ -2595,30 +2655,30 @@ async fn morph( Graphic::GradientList(gradient_list) }; - let graphic = match (a.element(0), b.element(0)) { - (Some(Graphic::ColorList(color_list_a)), Some(Graphic::ColorList(color_list_b))) => color_list_a - .element(0) - .zip(color_list_b.element(0)) - .map(|(color_a, color_b)| Graphic::from(color_a.lerp(color_b, time as f32))), - (Some(Graphic::ColorList(color_list_a)), Some(Graphic::GradientList(gradient_list_b))) => color_list_a.element(0).zip(gradient_list_b.element(0)).map(|(color_a, stops_b)| { + let graphic = match (a.element(0).and_then(InterpolablePaint::from_graphic), b.element(0).and_then(InterpolablePaint::from_graphic)) { + (Some(InterpolablePaint::Color(color_a)), Some(InterpolablePaint::Color(color_b))) => Some(Graphic::from(color_a.lerp(color_b, time as f32))), + (Some(InterpolablePaint::Color(color_a)), Some(InterpolablePaint::Gradient(gradient_list_b))) => gradient_list_b.element(0).cloned().map(|stops_b| { let solid_to_gradient = stops_b.map_colors(|_| *color_a); - let stops = solid_to_gradient.lerp(stops_b, time); - gradient_with_stops(gradient_list_b.clone(), stops) + let stops = solid_to_gradient.lerp(&stops_b, time); + gradient_with_stops(gradient_list_b, stops) }), - (Some(Graphic::GradientList(gradient_list_a)), Some(Graphic::ColorList(color_list_b))) => gradient_list_a.element(0).zip(color_list_b.element(0)).map(|(stops_a, color_b)| { + (Some(InterpolablePaint::Gradient(gradient_list_a)), Some(InterpolablePaint::Color(color_b))) => gradient_list_a.element(0).cloned().map(|stops_a| { let gradient_to_solid = stops_a.map_colors(|_| *color_b); let stops = stops_a.lerp(&gradient_to_solid, time); - gradient_with_stops(gradient_list_a.clone(), stops) + gradient_with_stops(gradient_list_a, stops) }), - (Some(Graphic::GradientList(gradient_list_a)), Some(Graphic::GradientList(gradient_list_b))) => gradient_list_a.element(0).zip(gradient_list_b.element(0)).map(|(stops_a, stops_b)| { - let stops = stops_a.lerp(stops_b, time); - let metadata_source = if time < 0.5 { gradient_list_a } else { gradient_list_b }; + (Some(InterpolablePaint::Gradient(gradient_list_a)), Some(InterpolablePaint::Gradient(gradient_list_b))) => gradient_list_a + .element(0) + .zip(gradient_list_b.element(0)) + .map(|(stops_a, stops_b)| stops_a.lerp(stops_b, time)) + .map(|stops| { + let transform = lerp_gradient_transform(&gradient_list_a, &gradient_list_b, time); - let mut gradient_list = metadata_source.clone(); - gradient_list.set_attribute(ATTR_TRANSFORM, 0, lerp_gradient_transform(gradient_list_a, gradient_list_b, time)); + let mut gradient_list = if time < 0.5 { gradient_list_a } else { gradient_list_b }; + gradient_list.set_attribute(ATTR_TRANSFORM, 0, transform); - gradient_with_stops(gradient_list, stops) - }), + gradient_with_stops(gradient_list, stops) + }), // Pairings beyond solid colors and gradients (raster, vector, or mixed) can't be interpolated, so step at the midpoint _ => return Some(if time < 0.5 { a.clone() } else { b.clone() }), }; @@ -4106,4 +4166,45 @@ mod test { assert_eq!(beveled.point_domain.positions().len(), 6); assert_eq!(beveled.segment_domain.ids().len(), 5); } + + // A rank-0 vector reaches the same per-list styling path as a vector list, rather than being skipped + #[tokio::test] + async fn assign_colors_reaches_rank_0_vector_graphics() { + let leaf = Graphic::Vector(Box::new(vector_item_from_bezpath(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY)))); + let content = List::new_from_element(leaf); + + let styled = super::assign_colors( + Footprint::default(), + content, + Item::new_from_element(true), + Item::new_from_element(false), + Item::new_from_element(Gradient::from(vec![Color::BLACK, Color::WHITE])), + Item::new_from_element(false), + Item::new_from_element(false), + Item::new_from_element(SeedValue::default()), + Item::new_from_element(0_u32), + ) + .await; + + let Some(Graphic::Vector(item)) = styled.element(0) else { + panic!("the leaf should stay a rank-0 vector") + }; + let appearance = item.attribute::(ATTR_APPEARANCE).expect("the leaf should have gained an appearance"); + assert!(appearance.has_painted_cover(Cover::Fill), "the fill of a rank-0 vector should be styled like a list element"); + } + + // Fill's automatic gradient placement measures rank-0 vector content instead of falling back to the unit box + #[test] + fn vector_item_mut_reaches_a_rank_0_vector_graphic() { + let transform = DAffine2::from_translation(DVec2::new(7., 3.)); + let item = create_vector_item(Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY), transform); + let mut content = Item::new_from_element(Graphic::Vector(Box::new(item))); + + let mut visited = Vec::new(); + content.for_each_vector_mut(|vector, vector_transform| visited.push((vector.bounding_box(), vector_transform))); + + assert_eq!(visited.len(), 1, "the lone vector should be visited exactly once"); + assert_eq!(visited[0].1, transform, "its own transform attribute should come along for placement"); + assert!(visited[0].0.is_some(), "its geometry should be measurable for the automatic gradient bounds"); + } }