Add renderer support for painting vector with "fill" and "stroke" attributes with graphic List<T> types (#4111)

* Add conversion from Fill to Table<Graphic>

* Refactor Vector vello renderer for Gradient / Color

* Refactor Vector SVG renderer for Gradient / Color

* Fix conflicts

* Add basic clipping-based fill for SVG rendering

* Use Cow to avoid cloning graphic list for fill

* Cleanup for Cow usage

* format code

* Use `<pattern>` instead of `<clipPath>` for clip

This simplifies the future implementation of clipping-based rendering
for strokes, as the stroke does not support the use of a clip path but
rather paint sources from a paint server.

* Move svg pattern rendering function to RenderExt

* Fix comment

* Fix empty fill list rendering as default black

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Move opaque check function to Graphic impl

* Add color converter and debug node to use graphic

* WIP: Use List<Graphic> to render Color & Gradient

* Use `Arc<List<Vector>>` for vector_data metadata

This exposes List's attributes to message handlers, enabling them to
access the necessary attribute data such as ATTR_STROKE_PAINT_GRAPHIC
as `Fill` and `Stroke` will not have paint information in the future.

* Recurse opacity checks on nested `Graphic`

Also extracts `fill_graphic_list_at` /
`stroke_paint_graphic_list_at` to share the row-attribute
lookup across the existing call sites.

* Fix fill and stroke visibility check degradation

* Fix clipping based stroke paint positioning

* Refactor vello renderer for stroke to use graphic

* Reduce `Fill` / `Stroke.color` to `List<Graphic>` allocations

* Revert "Use `Arc<List<Vector>>` for vector_data metadata"

This reverts commit 4285243a5d

* Expose paint row attributes as dedicated metadata for vectors

Add `fill_attributes` / `stroke_paint_attributes` to `DocumentMetadata`
so the `ExpandFillStrokeOnSelectedLayers` handler can read row paint
visibility without exposing entire `List<Vector>`.

* Fix transparency check to consider fill opacity

* Fix consistency of gradient placement for SVG stroke

* Rename `stroke_paint_..` to `stroke_..`

* Remove debug nodes

* Allow to use any graphic type without casting

* Rename `fill_graphic` / `stroke_graphic` to `fill` / `stroke`

* Fix SVG pattern placement when stroke transform differs from item transform

* Fix click target fill check for empty list in graphic

* Fix blank fill/stroke attribute masking legacy style

* Fix SVG's paint order trick for vector/raster fills

* Add zero-division guard for pattern wraparound prevention

* Code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
YohYamasaki
2026-06-11 21:50:27 +02:00
committed by Timon
parent e524dff1a7
commit 2b8ef42086
15 changed files with 976 additions and 259 deletions

View File

@@ -12,6 +12,8 @@ use crate::messages::prelude::*;
use glam::{DAffine2, IVec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::raster::BlendMode;
use graphene_std::raster::Image;
use graphene_std::transform::Footprint;
@@ -235,6 +237,16 @@ pub enum DocumentMessage {
UpdateVectorData {
vector_data: HashMap<NodeId, Arc<Vector>>,
},
// `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it.
// `UpdateFillAttributes` and `UpdateStrokeAttributes` are produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point.
#[serde(skip)]
UpdateFillAttributes {
fill_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
},
#[serde(skip)]
UpdateStrokeAttributes {
stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
},
Undo,
UngroupSelectedLayers,
UngroupLayer {

View File

@@ -1404,6 +1404,34 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
.collect();
self.network_interface.update_vector_data(layer_vector_data);
}
DocumentMessage::UpdateFillAttributes { fill_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_fill_attributes = fill_attributes
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| {
self.network_interface.is_layer(&node_id, &[]).then(|| {
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
(layer, attrs)
})
})
.collect();
self.network_interface.update_fill_attributes(layer_fill_attributes);
}
DocumentMessage::UpdateStrokeAttributes { stroke_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_stroke_attributes = stroke_attributes
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| {
self.network_interface.is_layer(&node_id, &[]).then(|| {
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
(layer, attrs)
})
})
.collect();
self.network_interface.update_stroke_attributes(layer_stroke_attributes);
}
DocumentMessage::Undo => {
if self.network_interface.transaction_status() != TransactionStatus::Finished {
return;
@@ -2486,10 +2514,23 @@ impl DocumentMessageHandler {
continue;
};
let has_fill = !matches!(style.fill, Fill::None);
let fill_graphic_list = self.network_interface.document_metadata().layer_fill_attributes.get(&layer);
let stroke_graphic_list = self.network_interface.document_metadata().layer_stroke_attributes.get(&layer);
let has_fill = if let Some(list) = fill_graphic_list {
list.element(0).is_some()
} else {
!matches!(style.fill, Fill::None)
};
// `style.stroke` is `Some` whenever a `Stroke` node is in the chain, even with weight 0 or a transparent color.
// So `is_some()` would treat invisibly-stroked fill-only layers as having a stroke.
let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke());
// `ATTR_STROKE` is the source of truth when set; fall back to `style.stroke.color` only when no attribute is present.
let stroke_visible = if let Some(list) = stroke_graphic_list {
list.element(0).is_some_and(|g| !g.is_fully_transparent())
} else {
style.stroke.as_ref().and_then(|s| s.color()).is_some_and(|c| c.a() != 0.)
};
let has_stroke = style.stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()) && stroke_visible;
// No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip.
if !has_stroke {

View File

@@ -6,6 +6,8 @@ use crate::messages::portfolio::document::utility_types::network_interface::Flow
use crate::messages::tool::common_functionality::graph_modification_utils;
use glam::{DAffine2, DVec2};
use graph_craft::document::NodeId;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
use graphene_std::subpath;
use graphene_std::transform::Footprint;
@@ -39,6 +41,12 @@ pub struct DocumentMetadata {
/// Vector data keyed by layer ID, used as fallback when no Path node exists.
/// This provides accurate SegmentIds for layers without explicit Path nodes.
pub layer_vector_data: HashMap<LayerNodeIdentifier, Arc<Vector>>,
/// Per-layer `ATTR_FILL` attribute, exposed so message handlers can read paint
/// information that lives on the list.
pub layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>,
/// Per-layer `ATTR_STROKE` attribute, exposed so message handlers can read
/// stroke paint information that lives on the list.
pub layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>,
/// Transform from document space to viewport space.
pub document_to_viewport: DAffine2,
}

View File

@@ -24,6 +24,8 @@ use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork};
use graphene_std::ContextDependencies;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint;
@@ -3401,6 +3403,16 @@ impl NodeNetworkInterface {
pub fn update_vector_data(&mut self, new_layer_vector_data: HashMap<LayerNodeIdentifier, Arc<Vector>>) {
self.document_metadata.layer_vector_data = new_layer_vector_data;
}
/// Update the per-layer `ATTR_FILL` snapshot.
pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>) {
self.document_metadata.layer_fill_attributes = new_layer_fill_attributes;
}
/// Update the per-layer `ATTR_STROKE` snapshot.
pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>) {
self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes;
}
}
// Public mutable methods

View File

@@ -446,6 +446,8 @@ impl NodeGraphExecutor {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
backgrounds: _,
} = render_output.metadata;
@@ -460,6 +462,8 @@ impl NodeGraphExecutor {
responses.add(DocumentMessage::UpdateTextFrames { text_frames });
responses.add(DocumentMessage::UpdateClipTargets { clip_targets });
responses.add(DocumentMessage::UpdateVectorData { vector_data });
responses.add(DocumentMessage::UpdateFillAttributes { fill_attributes });
responses.add(DocumentMessage::UpdateStrokeAttributes { stroke_attributes });
responses.add(DocumentMessage::RenderScrollbars);
responses.add(DocumentMessage::RenderRulers);
responses.add(OverlaysMessage::Draw);

View File

@@ -88,6 +88,9 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
convert_node!(from: List<NodeId>, to: AttributeValueDyn),
convert_node!(from: List<Color>, to: AttributeValueDyn),
convert_node!(from: List<GradientStops>, to: AttributeValueDyn),
convert_node!(from: List<Vector>, to: AttributeValueDyn),
convert_node!(from: List<Raster<CPU>>, to: AttributeValueDyn),
convert_node!(from: List<Raster<GPU>>, to: AttributeValueDyn),
convert_node!(from: List<Graphic>, to: AttributeValueDyn),
// into_node!(from: List<Raster<CPU>>, to: List<Raster<SRGBA8>>),
#[cfg(feature = "gpu")]

View File

@@ -77,6 +77,12 @@ pub const ATTR_SPREAD_METHOD: &str = "spread_method";
/// Gradient's `GradientType` (`Linear` or `Radial`).
pub const ATTR_GRADIENT_TYPE: &str = "gradient_type";
/// Vector graphics object's filled area paint, of type List<T> where T is any graphic type.
pub const ATTR_FILL: &str = "fill";
/// Vector graphics object's stroke paint, of type List<T> where T is any graphic type.
pub const ATTR_STROKE: &str = "stroke";
// ===========================
// Implicit attribute defaults
// ===========================
@@ -655,6 +661,22 @@ impl ItemAttributeValues {
}
})
}
/// Moves the attribute at `from_key` to `to_key`.
/// Does nothing if `from_key` is absent, overwrites any existing `to_key`.
pub fn rename(&mut self, from_key: &str, to_key: impl Into<String>) {
let Some(pos) = self.0.iter().position(|(k, _)| k == from_key) else { return };
let (_, value) = self.0.remove(pos);
let to_key = to_key.into();
for (existing_key, existing_value) in &mut self.0 {
if *existing_key == to_key {
*existing_value = value;
return;
}
}
self.0.push((to_key, value));
}
}
// ==========

View File

@@ -56,7 +56,7 @@ impl<T: ToString + Send> Convert<String, ()> for T {
}
pub trait ListConvert<U> {
fn convert_row(self) -> U;
fn convert_item(self) -> U;
}
impl<U, T: ListConvert<U> + Send> Convert<List<U>, ()> for List<T> {
@@ -65,7 +65,7 @@ impl<U, T: ListConvert<U> + Send> Convert<List<U>, ()> for List<T> {
.into_iter()
.map(|row| {
let (element, attributes) = row.into_parts();
Item::from_parts(element.convert_row(), attributes)
Item::from_parts(element.convert_item(), attributes)
})
.collect();
list

View File

@@ -8,7 +8,7 @@ use glam::DAffine2;
/// Nominal wrapper around `List<Graphic>` representing a single artboard's content.
///
/// Per-artboard metadata (location, dimensions, background, clip) lives as row attributes on the
/// Per-artboard metadata (location, dimensions, background, clip) lives as attributes on the
/// enclosing `List<Artboard>`, not as fields here. This keeps `Artboard` a pure type-system boundary
/// that prevents arbitrary `List<List<...<Graphic>>>` nesting.
#[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)]

View File

@@ -1,17 +1,17 @@
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::List;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List};
use core_types::ops::ListConvert;
use core_types::render_complexity::RenderComplexity;
use core_types::uuid::NodeId;
use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color};
use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color};
use dyn_any::DynAny;
use glam::DAffine2;
use raster_types::{CPU, GPU, Raster};
use std::borrow::Cow;
use vector_types::GradientStops;
// use vector_types::Vector;
pub use vector_types::Vector;
use vector_types::vector::style::Fill;
/// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax.
#[derive(Clone, Debug, CacheHash, PartialEq, DynAny)]
@@ -107,21 +107,21 @@ impl From<List<GradientStops>> for Graphic {
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
for current_graphic_row in current_graphic_list.into_iter() {
for current_graphic_item in current_graphic_list.into_iter() {
// Whether the parent carries each attribute: a structural fact (column presence), never a value comparison.
// Flattening composes a parent attribute onto its children only when the parent has it,
// so an absent parent attribute never invents a column the children didn't already have.
let parent_has_transform = current_graphic_row.attribute::<DAffine2>(ATTR_TRANSFORM).is_some();
let parent_has_opacity = current_graphic_row.attribute::<f64>(ATTR_OPACITY).is_some();
let parent_has_fill = current_graphic_row.attribute::<f64>(ATTR_OPACITY_FILL).is_some();
let parent_has_layer_path = current_graphic_row.attribute::<List<NodeId>>(ATTR_EDITOR_LAYER_PATH).is_some();
let parent_has_transform = current_graphic_item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some();
let parent_has_opacity = current_graphic_item.attribute::<f64>(ATTR_OPACITY).is_some();
let parent_has_fill = current_graphic_item.attribute::<f64>(ATTR_OPACITY_FILL).is_some();
let parent_has_layer_path = current_graphic_item.attribute::<List<NodeId>>(ATTR_EDITOR_LAYER_PATH).is_some();
let layer_path: List<NodeId> = current_graphic_row.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH);
let current_transform: DAffine2 = current_graphic_row.attribute_cloned_or_default(ATTR_TRANSFORM);
let current_opacity: f64 = current_graphic_row.attribute_cloned_or(ATTR_OPACITY, 1.);
let current_fill: f64 = current_graphic_row.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
let layer_path: List<NodeId> = current_graphic_item.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH);
let current_transform: DAffine2 = current_graphic_item.attribute_cloned_or_default(ATTR_TRANSFORM);
let current_opacity: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY, 1.);
let current_fill: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
match current_graphic_row.into_element() {
match current_graphic_item.into_element() {
// Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries.
// A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op.
Graphic::Graphic(mut sub_list) => {
@@ -150,16 +150,16 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
// Each `|| item.attribute(...)` keeps an attribute the item itself carries
// (recomposed with the parent's identity value) even when the parent lacks it
if parent_has_transform || item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some() {
let row_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
item.set_attribute(ATTR_TRANSFORM, current_transform * row_transform);
let item_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
item.set_attribute(ATTR_TRANSFORM, current_transform * item_transform);
}
if parent_has_opacity || item.attribute::<f64>(ATTR_OPACITY).is_some() {
let row_opacity: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.);
item.set_attribute(ATTR_OPACITY, current_opacity * row_opacity);
let item_opacity: f64 = item.attribute_cloned_or(ATTR_OPACITY, 1.);
item.set_attribute(ATTR_OPACITY, current_opacity * item_opacity);
}
if parent_has_fill || item.attribute::<f64>(ATTR_OPACITY_FILL).is_some() {
let row_fill: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
item.set_attribute(ATTR_OPACITY_FILL, current_fill * row_fill);
let item_fill: f64 = item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
item.set_attribute(ATTR_OPACITY_FILL, current_fill * item_fill);
}
if parent_has_layer_path {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
@@ -178,6 +178,123 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
output
}
/// Converts a `Fill` enum into the `List<Graphic>` representation used as paint storage.
/// TODO: Remove once all fill paint sources flow through `List<Graphic>` directly without going through the `Fill` enum.
pub fn fill_to_graphic_list(fill: &Fill) -> Option<List<Graphic>> {
match fill {
Fill::None => None,
Fill::Solid(color) => Some(List::new_from_element((*color).into())),
Fill::Gradient(gradient) => {
let gradient_item = Item::new_from_element(gradient.stops.clone())
.with_attribute(ATTR_TRANSFORM, gradient.to_transform())
.with_attribute(ATTR_GRADIENT_TYPE, gradient.gradient_type)
.with_attribute(ATTR_SPREAD_METHOD, gradient.spread_method);
let gradient_list = List::new_from_item(gradient_item);
Some(List::new_from_element(Graphic::Gradient(gradient_list)))
}
}
}
/// Converts a `Color` into the `List<Graphic>` representation used as paint storage.
/// TODO: Remove once all stroke paint sources flow through `List<Graphic>` directly without going through `Stroke.color`.
pub fn color_to_graphic_list(color: Option<Color>) -> Option<List<Graphic>> {
color.as_ref().map(|color| List::new_from_element((*color).into()))
}
/// Look up the paint graphics stored under attribute for a vector item, normalizing any graphic list type to `List<Graphic>`.
pub fn graphic_list_at<'a>(list: &'a List<Vector>, index: usize, attribute: &str) -> Option<Cow<'a, List<Graphic>>> {
list.attribute::<List<Graphic>>(attribute, index)
.map(Cow::Borrowed)
.or_else(|| list.attribute::<List<Color>>(attribute, index).map(|c| Cow::Owned(c.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<GradientStops>>(attribute, index).map(|g| Cow::Owned(g.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Vector>>(attribute, index).map(|v| Cow::Owned(v.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Raster<CPU>>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Raster<GPU>>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list())))
// Treat a blank attribute as absent so consumers fall back to the legacy `style` instead of masking it.
.filter(|graphic_list| graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty()))
}
/// Look up the fill paint graphics for a vector item, falling back to the legacy
/// `style.fill` when the attribute is absent or empty.
/// TODO: Remove once all fill paint sources flow through `List<Graphic>` directly without going through the `Fill` enum.
pub fn fill_graphic_list_at(list: &List<Vector>, index: usize) -> Option<Cow<'_, List<Graphic>>> {
graphic_list_at(list, index, ATTR_FILL).or_else(|| {
let vector = list.element(index)?;
fill_to_graphic_list(vector.style.fill()).map(Cow::Owned)
})
}
/// Look up the stroke paint graphics for a vector item, falling back to the legacy
/// `style.stroke.color` when the attribute is absent or empty.
/// TODO: Remove once all stroke paint sources flow through `List<Graphic>` directly without going through `Stroke.color`.
pub fn stroke_graphic_list_at(list: &List<Vector>, index: usize) -> Option<Cow<'_, List<Graphic>>> {
graphic_list_at(list, index, ATTR_STROKE).or_else(|| {
let vector = list.element(index)?;
color_to_graphic_list(vector.style.stroke().and_then(|s| s.color())).map(Cow::Owned)
})
}
/// Check whether the fill paint for a vector item is fully opaque, falling back to
/// the legacy `style.fill` when the attribute is absent.
/// This avoids the `List<Graphic>` allocation that the legacy `Fill` fallback path performs.
/// TODO: Remove once all fill paint sources flow through `List<Graphic>` directly without going through the `Fill` enum.
pub fn is_fill_opaque_at(list: &List<Vector>, index: usize) -> bool {
if let Some(graphic_list) = graphic_list_at(list, index, ATTR_FILL) {
return graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque());
}
let Some(vector) = list.element(index) else { return false };
match vector.style.fill() {
Fill::None => false,
Fill::Solid(color) => color.is_opaque(),
Fill::Gradient(gradient) => gradient.stops.iter().all(|stop| stop.color.is_opaque()),
}
}
/// Check whether the fill paint for a vector item is fully transparent, falling back to
/// the legacy `style.fill` when the attribute is absent.
/// This avoids the `List<Graphic>` allocation that the legacy `Fill` fallback path performs.
/// TODO: Remove once all fill paint sources flow through `List<Graphic>` directly without going through the `Fill` enum.
pub fn is_fill_fully_transparent_at(list: &List<Vector>, index: usize) -> bool {
if let Some(graphic_list) = graphic_list_at(list, index, ATTR_FILL) {
return graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent());
}
let Some(vector) = list.element(index) else { return false };
match vector.style.fill() {
Fill::None => true,
Fill::Solid(color) => color.a() == 0.,
Fill::Gradient(gradient) => gradient.stops.iter().all(|stop| stop.color.a() == 0.),
}
}
/// Check whether the stroke paint for a vector item is fully opaque, falling back to
/// the legacy `style.stroke.color` when the attribute is absent.
/// This avoids the `List<Graphic>` allocation that the legacy `Stroke.color` fallback path performs.
/// TODO: Remove once all stroke paint sources flow through `List<Graphic>` directly without going through `Stroke.color`.
pub fn is_stroke_opaque_at(list: &List<Vector>, index: usize) -> bool {
if let Some(graphic_list) = graphic_list_at(list, index, ATTR_STROKE) {
return graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque());
}
let Some(color) = list.element(index).and_then(|vector| vector.style.stroke()).and_then(|stroke| stroke.color()) else {
return false;
};
color.is_opaque()
}
/// Check whether the stroke paint for a vector item is fully transparent, falling back to
/// the legacy `style.stroke.color` when the attribute is absent.
/// This avoids the `List<Graphic>` allocation that the legacy `Stroke.color` fallback path performs.
/// TODO: Remove once all stroke paint sources flow through `List<Graphic>` directly without going through `Stroke.color`.
pub fn is_stroke_fully_transparent_at(list: &List<Vector>, index: usize) -> bool {
if let Some(graphic_list) = graphic_list_at(list, index, ATTR_STROKE) {
return graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent());
}
let Some(color) = list.element(index).and_then(|vector| vector.style.stroke()).and_then(|stroke| stroke.color()) else {
return true;
};
color.a() == 0.
}
/// Maps from a concrete element type to its corresponding `Graphic` enum variant,
/// enabling type-directed casting of typed `List`s from a `Graphic` value.
pub trait TryFromGraphic: Clone + Sized {
@@ -229,7 +346,7 @@ impl IntoGraphicList for List<Graphic> {
impl IntoGraphicList for List<Vector> {
fn into_graphic_list(self) -> List<Graphic> {
// Propagate `editor:layer_path` from item 0 onto the wrapper Graphic row so a subsequent
// Propagate `editor:layer_path` from item 0 onto the wrapper Graphic item so a subsequent
// `flatten_graphic_list` doesn't overwrite the inner Vector's stamp with an empty value
let layer_path: List<NodeId> = self.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, 0);
let mut graphic_list = List::new_from_element(Graphic::Vector(self));
@@ -341,11 +458,82 @@ impl Graphic {
Graphic::Vector(vector) => (0..vector.len()).all(|index| {
let Some(element) = vector.element(index) else { return false };
let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.);
opacity > 1. - f64::EPSILON && element.style.fill().is_opaque() && element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke())
let fill_opaque_or_absent = match graphic_list_at(vector, index, ATTR_FILL) {
Some(graphic_list) => graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()),
None => element.style.fill().is_opaque(),
};
let stroke_invisible_or_transparent = element.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| if let Some(graphic_list) = graphic_list_at(vector, index, ATTR_STROKE) {
graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent())
} else {
element.style.stroke().and_then(|stroke| stroke.color()).is_none_or(|color| color.a() == 0.)
};
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
}),
_ => false,
}
}
pub fn is_opaque(&self) -> bool {
match self {
Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque),
Graphic::Vector(list) => {
!list.is_empty()
&& (0..list.len()).all(|i| {
let Some(vector) = list.element(i) else { return false };
let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.);
let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.);
let fill_opaque = opacity_fill >= 1. - f64::EPSILON && is_fill_opaque_at(list, i);
let stroke_opaque_or_invisible = vector.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_stroke_opaque_at(list, i);
opacity >= 1. - f64::EPSILON && fill_opaque && stroke_opaque_or_invisible
})
}
Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()),
Graphic::Gradient(list) => list.element(0).is_some_and(|stops| stops.iter().all(|stop| stop.color.is_opaque())),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false,
}
}
pub fn is_fully_transparent(&self) -> bool {
match self {
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
Graphic::Vector(list) => (0..list.len()).all(|i| {
let Some(vector) = list.element(i) else { return false };
let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.);
if opacity <= f64::EPSILON {
return true;
}
let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.);
let fill_invisible = opacity_fill <= f64::EPSILON || is_fill_fully_transparent_at(list, i);
let stroke_invisible = vector.style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_stroke_fully_transparent_at(list, i);
fill_invisible && stroke_invisible
}),
Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.),
Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => false,
}
}
/// True if this paint opaquely covers the entire fill region.
/// Vector, Raster, and a nested Graphic may leave gaps, so they return false.
pub fn covers_opaquely(&self) -> bool {
matches!(self, Graphic::Color(_) | Graphic::Gradient(_)) && self.is_opaque()
}
/// Returns true if this graphic's inner list is empty.
pub fn is_empty(&self) -> bool {
match self {
Graphic::Graphic(list) => list.is_empty(),
Graphic::Vector(list) => list.is_empty(),
Graphic::Color(list) => list.is_empty(),
Graphic::Gradient(list) => list.is_empty(),
Graphic::RasterCPU(list) => list.is_empty(),
Graphic::RasterGPU(list) => list.is_empty(),
}
}
}
impl BoundingBox for Graphic {
@@ -373,17 +561,17 @@ impl BoundingBox for Graphic {
}
impl ListConvert<Graphic> for Vector {
fn convert_row(self) -> Graphic {
fn convert_item(self) -> Graphic {
Graphic::Vector(List::new_from_element(self))
}
}
impl ListConvert<Graphic> for Raster<CPU> {
fn convert_row(self) -> Graphic {
fn convert_item(self) -> Graphic {
Graphic::RasterCPU(List::new_from_element(self))
}
}
impl ListConvert<Graphic> for Raster<GPU> {
fn convert_row(self) -> Graphic {
fn convert_item(self) -> Graphic {
Graphic::RasterGPU(List::new_from_element(self))
}
}
@@ -423,9 +611,9 @@ impl<T: Clone> AtIndex for List<T> {
type Output = List<T>;
fn at_index(&self, index: usize) -> Option<Self::Output> {
self.clone_item(index).map(|row| {
self.clone_item(index).map(|item| {
let mut result_list = Self::default();
result_list.push(row);
result_list.push(item);
result_list
})
}
@@ -456,9 +644,9 @@ impl<T: Clone> OmitIndex for List<T> {
let mut result = Self::default();
for i in 0..self.len() {
if i != index
&& let Some(row) = self.clone_item(i)
&& let Some(item) = self.clone_item(i)
{
result.push(row);
result.push(item);
}
}
result
@@ -505,3 +693,79 @@ mod tests {
assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5);
}
}
#[cfg(test)]
mod graphic_is_opaque_tests {
use vector_types::{GradientSpreadMethod, GradientStop};
use super::*;
fn color_graphic(alpha: f64) -> Graphic {
let color = Color::from_rgbaf32(1.0, 0.0, 0.0, alpha as f32).unwrap();
Graphic::Color(List::new_from_element(color))
}
fn gradient_graphic(gradient: GradientStops) -> Graphic {
let mut gradient_list = List::new_from_element(gradient);
gradient_list.set_attribute(ATTR_SPREAD_METHOD, 0, GradientSpreadMethod::Pad);
Graphic::Gradient(gradient_list)
}
#[test]
fn opaque_color_is_opaque() {
let g = color_graphic(1.0);
assert!(g.is_opaque());
}
#[test]
fn transparent_color_is_not_opaque() {
let g = color_graphic(0.5);
assert!(!g.is_opaque());
}
#[test]
fn vector_is_not_opaque() {
let g = Graphic::Vector(List::default());
assert!(!g.is_opaque());
}
#[test]
fn gradient_with_all_opaque_stops_is_opaque() {
let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap();
let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap();
let gradient = GradientStops::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: color_1,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: color_2,
},
]);
let g = gradient_graphic(gradient);
assert!(g.is_opaque());
}
#[test]
fn gradient_with_transparent_stop_is_not_opaque() {
let color_1 = Color::from_rgbaf32(1.0, 0.0, 0.0, 0.5).unwrap();
let color_2 = Color::from_rgbaf32(1.0, 0.0, 0.0, 1.).unwrap();
let gradient = GradientStops::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
color: color_1,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: color_2,
},
]);
let g = gradient_graphic(gradient);
assert!(!g.is_opaque());
}
}

View File

@@ -1,24 +1,104 @@
use crate::renderer::{RenderParams, format_transform_matrix};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::color::SRGBA8;
use core_types::list::List;
use core_types::uuid::generate_uuid;
use glam::DAffine2;
use graphic_types::vector_types::gradient::{Gradient, GradientType};
use graphic_types::vector_types::vector::style::{Fill, PaintOrder, PathStyle, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color};
use glam::{DAffine2, DVec2};
use graphic_types::Graphic;
use graphic_types::vector_types::gradient::GradientType;
use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use std::fmt::Write;
use vector_types::GradientStops;
use vector_types::gradient::GradientSpreadMethod;
#[derive(Copy, Clone, PartialEq)]
pub enum PaintTarget {
Fill,
Stroke,
}
impl PaintTarget {
fn paint_attr(self) -> &'static str {
match self {
Self::Fill => "fill",
Self::Stroke => "stroke",
}
}
fn opacity_attr(self) -> &'static str {
match self {
Self::Fill => "fill-opacity",
Self::Stroke => "stroke-opacity",
}
}
}
pub trait RenderExt {
type Output;
fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output;
#[allow(clippy::too_many_arguments)]
fn render(
&self,
svg_defs: &mut String,
item_transform: DAffine2,
element_transform: DAffine2,
stroke_transform: DAffine2,
bounds: DAffine2,
transformed_bounds: DAffine2,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output;
}
impl RenderExt for Gradient {
impl RenderExt for List<Color> {
type Output = String;
fn render(
&self,
_svg_defs: &mut String,
_item_transform: DAffine2,
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_transformed_bounds: DAffine2,
_render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
let Some(color) = self.element(0) else { return r#" fill="none""#.to_string() };
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.);
}
result
}
}
impl RenderExt for List<GradientStops> {
type Output = u64;
/// Adds the gradient def through mutating the first argument, returning the gradient ID.
fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, _render_params: &RenderParams) -> Self::Output {
fn render(
&self,
svg_defs: &mut String,
_item_transform: DAffine2,
element_transform: DAffine2,
stroke_transform: DAffine2,
bounds: DAffine2,
transformed_bounds: DAffine2,
_render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
let mut stop = String::new();
for (position, color, original_midpoint) in self.stops.interpolated_samples() {
let Some(stops) = self.element(0) else { return 0 };
let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0);
let gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0);
for (position, color, original_midpoint) in stops.interpolated_samples() {
stop.push_str("<stop");
if position != 0. {
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
@@ -33,9 +113,9 @@ impl RenderExt for Gradient {
stop.push_str(" />")
}
let transform_points = element_transform * stroke_transform * bounds;
let start = transform_points.transform_point2(self.start);
let end = transform_points.transform_point2(self.end);
let transform_points = element_transform * stroke_transform * bounds * gradient_transform;
let start = transform_points.transform_point2(DVec2::ZERO);
let end = transform_points.transform_point2(DVec2::X);
let gradient_transform = if transformed_bounds.matrix2.determinant() != 0. {
transformed_bounds.inverse()
@@ -49,15 +129,15 @@ impl RenderExt for Gradient {
format!(r#" gradientTransform="{gradient_transform}""#)
};
let spread_method = if self.spread_method == GradientSpreadMethod::Pad {
let spread_method = if spread_method == GradientSpreadMethod::Pad {
String::new()
} else {
format!(r#" spreadMethod="{}""#, self.spread_method.svg_name())
format!(r#" spreadMethod="{}""#, spread_method.svg_name())
};
let gradient_id = generate_uuid();
match self.gradient_type {
match gradient_type {
GradientType::Linear => {
let _ = write!(
svg_defs,
@@ -79,43 +159,22 @@ impl RenderExt for Gradient {
}
}
impl RenderExt for Fill {
type Output = String;
/// Renders the fill, adding necessary defs through mutating the first argument.
fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> Self::Output {
match self {
Self::None => r#" fill="none""#.to_string(),
Self::Solid(color) => {
let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
result
}
Self::Gradient(gradient) => {
let gradient_id = gradient.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
format!(r##" fill="url('#{gradient_id}')""##)
}
}
}
}
impl RenderExt for Stroke {
type Output = String;
/// Provide the SVG attributes for the stroke.
/// Provide the shape-related SVG attributes for the stroke. The paint-related attributes for the stroke are generated from `List<Graphic>.render` with `PaintTarget::Stroke`.
fn render(
&self,
_svg_defs: &mut String,
_item_transform: DAffine2,
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_transformed_bounds: DAffine2,
render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
// Don't render a stroke at all if it would be invisible
let Some(color) = self.color else { return String::new() };
if !self.has_renderable_stroke() {
return String::new();
}
@@ -133,10 +192,7 @@ impl RenderExt for Stroke {
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
let mut attributes = String::new();
if let Some(mut weight) = weight {
if stroke_align.is_some() && render_params.aligned_strokes {
weight *= 2.;
@@ -165,18 +221,84 @@ impl RenderExt for Stroke {
}
}
impl RenderExt for PathStyle {
impl RenderExt for List<Graphic> {
type Output = String;
/// Renders the shape's fill and stroke attributes as a string with them concatenated together.
#[allow(clippy::too_many_arguments)]
fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: DAffine2, transformed_bounds: DAffine2, render_params: &RenderParams) -> String {
let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
let stroke_attribute = self
.stroke
.as_ref()
.map(|stroke| stroke.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params))
.unwrap_or_default();
format!("{fill_attribute}{stroke_attribute}")
fn render(
&self,
svg_defs: &mut String,
item_transform: DAffine2,
element_transform: DAffine2,
stroke_transform: DAffine2,
bounds: DAffine2,
transformed_bounds: DAffine2,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
let fill_graphic = self.element(0);
let paint_attr = target.paint_attr();
match fill_graphic {
Some(Graphic::Color(color_list)) => color_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target),
Some(Graphic::Gradient(gradient_list)) => {
let gradient_id = gradient_list.render(svg_defs, item_transform, element_transform, stroke_transform, bounds, transformed_bounds, render_params, target);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) => {
let bounds = if target == PaintTarget::Stroke {
// To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly.
let inverse = |len: f64| if len > 0. { 1. / len } else { 0. };
let inflate = DVec2::new(inverse(item_transform.matrix2.x_axis.length()), inverse(item_transform.matrix2.y_axis.length()));
let min = bounds.transform_point2(DVec2::ZERO) - inflate;
let max = bounds.transform_point2(DVec2::ONE) + inflate;
DAffine2::from_scale_angle_translation(max - min, 0., min)
} else {
bounds
};
render_svg_pattern(svg_defs, self, stroke_transform, bounds, render_params)
.map(|id| format!(r##" {paint_attr}="url(#{id})""##))
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#))
}
None => format!(r#" {paint_attr}="none""#),
}
}
}
/// Emits an SVG `<pattern>` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID.
/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet.
fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List<Graphic>, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option<String> {
let min = bounds.transform_point2(DVec2::ZERO);
let max = bounds.transform_point2(DVec2::ONE);
let size = max - min;
if size.x <= 0. || size.y <= 0. {
return None;
}
// Render the pattern content recursively
let mut content = SvgRender::new();
fill_graphic_list.render_svg(&mut content, &render_params.for_pattern());
// Unwrap the inner def element
write!(svg_defs, "{}", content.svg_defs).unwrap();
let pattern_transform = stroke_transform * DAffine2::from_translation(min);
let transform_str = format_transform_matrix(pattern_transform);
let transform_attr = if transform_str.is_empty() {
String::new()
} else {
format!(r#" patternTransform="{transform_str}""#)
};
let pattern_id = format!("pattern-{}", generate_uuid());
write!(
svg_defs,
r##"<pattern id="{pattern_id}" patternUnits="userSpaceOnUse" x="0" y="0" width="{}" height="{}"{transform_attr}>"##,
size.x, size.y,
)
.unwrap();
let content_shift = format_transform_matrix(DAffine2::from_translation(-min));
write!(svg_defs, r##"<g transform="{content_shift}">{}</g></pattern>"##, content.svg.to_svg_string()).unwrap();
Some(pattern_id)
}

View File

@@ -1,4 +1,4 @@
use crate::render_ext::RenderExt;
use crate::render_ext::{PaintTarget, RenderExt};
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
use core_types::CacheHash;
use core_types::blending::BlendMode;
@@ -6,7 +6,7 @@ use core_types::bounds::BoundingBox;
use core_types::bounds::RenderBoundingBox;
use core_types::color::Color;
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List};
use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Footprint;
@@ -18,13 +18,14 @@ use core_types::{
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, is_stroke_fully_transparent_at, stroke_graphic_list_at};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster};
use graphic_types::vector_types::gradient::{GradientStops, GradientType};
use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign};
use graphic_types::vector_types::vector::style::{Fill, PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector};
use kurbo::{Affine, Cap, Join, Shape};
use kurbo::{Affine, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
@@ -218,6 +219,8 @@ pub struct RenderParams {
pub alignment_parent_transform: Option<DAffine2>,
pub aligned_strokes: bool,
pub override_paint_order: bool,
/// Are we rendering for a pattern content
pub inside_pattern: bool,
pub artboard_background: Option<Color>,
/// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode.
pub viewport_zoom: f64,
@@ -233,8 +236,12 @@ impl RenderParams {
Self { alignment_parent_transform, ..*self }
}
pub fn for_pattern(&self) -> Self {
Self { inside_pattern: true, ..*self }
}
pub fn to_canvas(&self) -> bool {
!self.for_export && !self.thumbnail && !self.for_mask
!self.for_export && !self.thumbnail && !self.for_mask && !self.inside_pattern
}
}
@@ -329,6 +336,103 @@ fn draw_raster_outline(scene: &mut Scene, outline_transform: &DAffine2, render_p
scene.stroke(&outline_stroke, Affine::IDENTITY, outline_color_peniko, None, &outline_path);
}
/// Emits an SVG `<path>` element with the resolved fill attribute corresponding to the given fill_graphic.
#[allow(clippy::too_many_arguments)]
fn emit_svg_fill_path(
render: &mut SvgRender,
d: String,
fill_graphic_list: Option<&List<Graphic>>,
item_transform: DAffine2,
element_transform: DAffine2,
applied_stroke_transform: DAffine2,
bounds_matrix: DAffine2,
transformed_bounds_matrix: DAffine2,
render_params: &RenderParams,
) {
render.leaf_tag("path", |attributes| {
attributes.push("d", d);
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let defs = &mut attributes.0.svg_defs;
let fill_attribute = fill_graphic_list
.map(|list| {
list.render(
defs,
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
PaintTarget::Fill,
)
})
.unwrap_or_else(|| r#" fill="none""#.to_string());
attributes.push_val(fill_attribute);
});
}
fn create_peniko_gradient_brush(gradient_list: &List<GradientStops>, parent_vector: &Vector, parent_transform: &DAffine2, multiplied_transform: &DAffine2) -> Option<peniko::Brush> {
let stops = gradient_list.element(0)?;
let gradient_type: GradientType = gradient_list.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0);
let gradient_transform: DAffine2 = gradient_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
let spread_method: GradientSpreadMethod = gradient_list.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0);
let mut peniko_stops = peniko::ColorStops::new();
for (position, color, _) in stops.interpolated_samples() {
peniko_stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
});
}
let bounds = parent_vector.nonzero_bounding_box();
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. {
parent_transform.inverse()
} else {
Default::default()
};
let mod_points = inverse_parent_transform * multiplied_transform * bound_transform * gradient_transform;
let start = mod_points.transform_point2(DVec2::ZERO);
let end = mod_points.transform_point2(DVec2::X);
let brush = peniko::Brush::Gradient(peniko::Gradient {
kind: match gradient_type {
GradientType::Linear => peniko::LinearGradientPosition {
start: to_point(start),
end: to_point(end),
}
.into(),
GradientType::Radial => {
let radius = start.distance(end);
peniko::RadialGradientPosition {
start_center: to_point(start),
start_radius: 0.,
end_center: to_point(start),
end_radius: radius as f32,
}
.into()
}
},
extend: match spread_method {
GradientSpreadMethod::Pad => peniko::Extend::Pad,
GradientSpreadMethod::Reflect => peniko::Extend::Reflect,
GradientSpreadMethod::Repeat => peniko::Extend::Repeat,
},
stops: peniko_stops,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
..Default::default()
});
Some(brush)
}
// TODO: Click targets can be removed from the render output, since the vector data is available in the vector modify data from Monitor nodes.
// This will require that the transform for child layers into that layer space be calculated, or it could be returned from the RenderOutput instead of click targets.
#[derive(Debug, Default, Clone, PartialEq, DynAny)]
@@ -346,6 +450,14 @@ pub struct RenderMetadata {
pub text_frames: HashMap<NodeId, DAffine2>,
pub clip_targets: HashSet<NodeId>,
pub vector_data: HashMap<NodeId, Arc<Vector>>,
/// Per-layer `ATTR_FILL` row attribute, exposed so message handlers can read paint
/// information that lives on the list rather than on `PathStyle.fill`.
#[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
/// Per-layer `ATTR_STROKE` row attribute, exposed so message handlers can read
/// stroke paint information that lives on the list rather than on `Stroke.color`.
#[cfg_attr(feature = "serde", serde(skip))]
pub stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
pub backgrounds: Vec<Background>,
}
@@ -369,6 +481,8 @@ impl RenderMetadata {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
backgrounds,
} = self;
upstream_footprints.extend(other.upstream_footprints.iter());
@@ -379,6 +493,8 @@ impl RenderMetadata {
text_frames.extend(other.text_frames.iter());
clip_targets.extend(other.clip_targets.iter());
vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone())));
fill_attributes.extend(other.fill_attributes.iter().map(|(id, data)| (*id, data.clone())));
stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone())));
// TODO: Find a better non O(n^2) way to merge backgrounds
for background in &other.backgrounds {
@@ -921,7 +1037,7 @@ impl Render for List<Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for index in 0..self.len() {
let Some(vector) = self.element(index) else { continue };
let multiplied_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
@@ -929,15 +1045,17 @@ impl Render for List<Vector> {
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = vector.style.stroke().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.);
let applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse());
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let transformed_bounds_matrix = element_transform * DAffine2::from_scale_angle_translation(transformed_bounds[1] - transformed_bounds[0], 0., transformed_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
let mut path = String::new();
@@ -952,32 +1070,35 @@ impl Render for List<Vector> {
MaskType::Mask
};
let fill_graphic_list = fill_graphic_list_at(self, index);
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
let stroke_graphic_list = stroke_graphic_list_at(self, index);
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
let can_draw_aligned_stroke = path_is_closed && vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered());
let can_use_paint_order = !(vector.style.fill().is_none() || !vector.style.fill().is_opaque() || mask_type == MaskType::Clip);
let can_draw_aligned_stroke = path_is_closed
&& vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.style.stroke().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
if needs_separate_alignment_fill && !wants_stroke_below {
render.leaf_tag("path", |attributes| {
attributes.push("d", path.clone());
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let mut style = vector.style.clone();
style.clear_stroke();
let fill_and_stroke = style.render(
&mut attributes.0.svg_defs,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
attributes.push_val(fill_and_stroke);
});
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
}
let push_id = needs_separate_alignment_fill.then_some({
@@ -989,35 +1110,27 @@ impl Render for List<Vector> {
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, multiplied_transform));
let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform));
(id, mask_type, vector_item)
});
let use_face_fill = vector.use_face_fill();
if use_face_fill {
for mut face_path in vector.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
let face_d = face_path.to_svg();
render.leaf_tag("path", |attributes| {
attributes.push("d", face_d.clone());
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let mut style = vector.style.clone();
style.clear_stroke();
let fill_only = style.render(
&mut attributes.0.svg_defs,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
attributes.push_val(fill_only);
});
emit_svg_fill_path(
render,
face_d,
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
}
}
@@ -1057,20 +1170,84 @@ impl Render for List<Vector> {
let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
render_params.override_paint_order = override_paint_order;
let mut style = vector.style.clone();
if needs_separate_alignment_fill || use_face_fill {
style.clear_fill();
}
let stroke_shape_attribute = vector
.style
.stroke()
.map(|stroke| {
if stroke_graphic_list.as_ref().and_then(|l| l.element(0)).is_some() {
stroke.render(
defs,
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
&render_params,
PaintTarget::Stroke,
)
} else {
String::new()
}
})
.unwrap_or_default();
let fill_and_stroke = style.render(defs, element_transform, applied_stroke_transform, bounds_matrix, transformed_bounds_matrix, &render_params);
// Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback
let stroke_visible = vector.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.as_deref()
.map(|list| {
// Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke.
// Only clipping-based paints need the stroke-inclusive bbox.
let paint_bounds = match list.element(0) {
Some(Graphic::Color(_)) | Some(Graphic::Gradient(_)) => bounds_matrix,
_ => stroke_bounds_matrix,
};
list.render(
defs,
item_transform,
element_transform,
applied_stroke_transform,
paint_bounds,
transformed_bounds_matrix,
&render_params,
PaintTarget::Stroke,
)
})
.unwrap_or_else(|| r#" stroke="none""#.to_string())
} else {
String::new()
};
let fill_attribute = if needs_separate_alignment_fill || use_face_fill {
r#" fill="none""#.to_string()
} else {
fill_graphic_list
.as_deref()
.map(|list| {
list.render(
defs,
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
&render_params,
PaintTarget::Fill,
)
})
.unwrap_or_else(|| r#" fill="none""#.to_string())
};
if let Some((id, mask_type, _)) = push_id {
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
attributes.push_val(fill_and_stroke);
attributes.push_val(fill_attribute);
attributes.push_val(stroke_shape_attribute);
attributes.push_val(stroke_attribute);
if vector.is_branching() && !use_face_fill {
attributes.push("fill-rule", "evenodd");
@@ -1088,31 +1265,22 @@ impl Render for List<Vector> {
// When splitting passes and stroke is below, draw the fill after the stroke.
if needs_separate_alignment_fill && wants_stroke_below {
render.leaf_tag("path", |attributes| {
attributes.push("d", path);
let matrix = format_transform_matrix(element_transform);
if !matrix.is_empty() {
attributes.push(ATTR_TRANSFORM, matrix);
}
let mut style = vector.style.clone();
style.clear_stroke();
let fill_and_stroke = style.render(
&mut attributes.0.svg_defs,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
attributes.push_val(fill_and_stroke);
});
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
item_transform,
element_transform,
applied_stroke_transform,
bounds_matrix,
transformed_bounds_matrix,
render_params,
);
}
}
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
use graphic_types::vector_types::vector::style::{GradientType, StrokeCap, StrokeJoin};
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
for index in 0..self.len() {
use graphic_types::vector_types::vector;
@@ -1146,6 +1314,9 @@ impl Render for List<Vector> {
}
}
let fill_graphic_list = fill_graphic_list_at(self, index);
let stroke_graphic_list = stroke_graphic_list_at(self, index);
// If we're using opacity or a blend mode, we need to push a layer
let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal,
@@ -1157,7 +1328,9 @@ impl Render for List<Vector> {
// Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since
// the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down.
let stroke = element.style.stroke();
let can_draw_aligned_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
let can_draw_aligned_stroke = !is_stroke_fully_transparent_at(self, index)
&& stroke.as_ref().is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered())
&& element.stroke_bezier_paths().all(|p| p.closed());
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
if opacity < 1. || blend_mode_attr != BlendMode::default() {
@@ -1181,75 +1354,43 @@ impl Render for List<Vector> {
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.as_ref().is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
// Closures to avoid duplicated fill/stroke drawing logic
let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() {
Fill::Solid(color) => {
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
}
Fill::Gradient(gradient) => {
let mut stops = peniko::ColorStops::new();
for (position, color, _) in gradient.stops.interpolated_samples() {
stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
});
}
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list.as_deref() else { return };
let bounds = element.nonzero_bounding_box();
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
for paint_index in 0..fill_graphic.len() {
let Some(paint) = fill_graphic.element(paint_index) else { continue };
match paint {
Graphic::Color(list) => {
let Some(color) = list.element(0) else { continue };
let inverse_parent_transform = if parent_transform.matrix2.determinant() != 0. {
parent_transform.inverse()
} else {
Default::default()
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
}
Graphic::Gradient(list) => {
let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else {
continue;
};
let inverse_element_transform = if element_transform.matrix2.determinant() != 0. {
element_transform.inverse()
} else {
Default::default()
};
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => {
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
paint.render_to_vello(scene, multiplied_transform, context, render_params);
scene.pop_layer();
}
};
let mod_points = inverse_parent_transform * multiplied_transform * bound_transform;
let start = mod_points.transform_point2(gradient.start);
let end = mod_points.transform_point2(gradient.end);
let fill = peniko::Brush::Gradient(peniko::Gradient {
kind: match gradient.gradient_type {
GradientType::Linear => peniko::LinearGradientPosition {
start: to_point(start),
end: to_point(end),
}
.into(),
GradientType::Radial => {
let radius = start.distance(end);
peniko::RadialGradientPosition {
start_center: to_point(start),
start_radius: 0.,
end_center: to_point(start),
end_radius: radius as f32,
}
.into()
}
},
extend: match gradient.spread_method {
GradientSpreadMethod::Pad => peniko::Extend::Pad,
GradientSpreadMethod::Reflect => peniko::Extend::Reflect,
GradientSpreadMethod::Repeat => peniko::Extend::Repeat,
},
stops,
interpolation_alpha_space: peniko::InterpolationAlphaSpace::Premultiplied,
..Default::default()
});
let inverse_element_transform = if element_transform.matrix2.determinant() != 0. {
element_transform.inverse()
} else {
Default::default()
};
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), path);
}
Fill::None => {}
};
// Branching vectors without regions (e.g. mesh grids) need face-by-face fill rendering.
let use_face_fill = element.use_face_fill();
let do_fill = |scene: &mut Scene| {
let do_fill = |scene: &mut Scene, context: &mut RenderContext| {
if use_face_fill {
for mut face_path in element.construct_faces().filter(|face| face.area() >= 0.) {
face_path.apply_affine(Affine::new(applied_stroke_transform.to_cols_array()));
@@ -1257,21 +1398,24 @@ impl Render for List<Vector> {
for element in face_path {
kurbo_path.push(element);
}
do_fill_path(scene, &kurbo_path, peniko::Fill::NonZero);
do_fill_path(scene, context, &kurbo_path, peniko::Fill::NonZero);
}
} else if element.is_branching() {
do_fill_path(scene, &path, peniko::Fill::EvenOdd);
do_fill_path(scene, context, &path, peniko::Fill::EvenOdd);
} else {
do_fill_path(scene, &path, peniko::Fill::NonZero);
do_fill_path(scene, context, &path, peniko::Fill::NonZero);
}
};
let do_stroke = |scene: &mut Scene, width_scale: f64| {
if let Some(stroke) = element.style.stroke() {
let color = match stroke.color {
Some(color) => SRGBA8::from(color).to_peniko_color(),
None => peniko::Color::TRANSPARENT,
let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| {
let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return };
let Some(stroke) = element.style.stroke() else { return };
for paint_index in 0..stroke_graphic_list.len() {
let Some(stroke_graphic) = stroke_graphic_list.element(paint_index) else {
continue;
};
let cap = match stroke.cap {
StrokeCap::Butt => Cap::Butt,
StrokeCap::Round => Cap::Round,
@@ -1293,9 +1437,38 @@ impl Render for List<Vector> {
dash_offset: stroke.dash_offset,
};
if stroke.width > 0. {
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path);
}
if stroke.width <= 0. {
continue;
};
match stroke_graphic {
Graphic::Color(list) => {
let Some(color) = list.element(0) else { continue };
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path);
}
Graphic::Gradient(list) => {
let Some(brush) = create_peniko_gradient_brush(list, element, &parent_transform, &multiplied_transform) else {
continue;
};
let inverse_element_transform = if element_transform.matrix2.determinant() != 0. {
element_transform.inverse()
} else {
Default::default()
};
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, Some(brush_transform), &path);
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) => {
let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01);
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked);
stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params);
scene.pop_layer();
}
};
}
};
@@ -1332,24 +1505,24 @@ impl Render for List<Vector> {
if wants_stroke_below {
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform));
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
do_stroke(scene, 2.);
do_stroke(scene, 2., context);
scene.pop_layer();
scene.pop_layer();
do_fill(scene);
do_fill(scene, context);
} else {
// Fill first (unclipped), then stroke (clipped) above
do_fill(scene);
do_fill(scene, context);
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
vector_list.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform));
vector_list.render_to_vello(scene, parent_transform, context, &render_params.for_alignment(applied_stroke_transform));
scene.push_layer(peniko::Fill::NonZero, peniko::BlendMode::new(peniko::Mix::Normal, compose), 1., kurbo::Affine::IDENTITY, &rect);
do_stroke(scene, 2.);
do_stroke(scene, 2., context);
scene.pop_layer();
scene.pop_layer();
@@ -1368,8 +1541,8 @@ impl Render for List<Vector> {
for operation in &order {
match operation {
Op::Fill => do_fill(scene),
Op::Stroke => do_stroke(scene, 1.),
Op::Fill => do_fill(scene, context),
Op::Stroke => do_stroke(scene, 1., context),
}
}
}
@@ -1421,17 +1594,28 @@ impl Render for List<Vector> {
let item_relative_transform = item_zero_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, click_target_vector, item_relative_transform);
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new));
// Outlines always use source geometry so the visual outline reflects actual letterforms
let mut outlines_unwrapped = Vec::new();
extend_targets_from_vector(&mut outlines_unwrapped, source, item_relative_transform);
extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform);
accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new));
// Source geometry (not the click-target override) so editing tools work on letterforms.
// Recorded together with `vector_data` from the same (first) row so `style` stays consistent with the paint.
// Only item 0 is recorded since editing tools can only target a single item currently.
metadata.vector_data.entry(element_id).or_insert_with(|| Arc::new(source.clone()));
// If that row has no paint attribute, none is recorded and consumers fall back to `style`.
if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
e.insert(Arc::new(source.clone()));
if let Some(fill_graphic) = graphic_list_at(self, index, ATTR_FILL) {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned()));
}
if let Some(stroke_graphic) = graphic_list_at(self, index, ATTR_STROKE) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned()));
}
}
// Surface `editor:text_frame` for the Text tool's drag cage
if let Some(&frame) = self.attribute::<DAffine2>(ATTR_EDITOR_TEXT_FRAME, index) {
@@ -1467,7 +1651,7 @@ impl Render for List<Vector> {
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
let vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
extend_targets_from_vector(click_targets, vector, transform);
extend_targets_from_vector(click_targets, self, index, vector, transform);
}
}
@@ -1477,7 +1661,7 @@ impl Render for List<Vector> {
let Some(source) = self.element(index) else { continue };
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
extend_targets_from_vector(outlines, source, transform);
extend_targets_from_vector(outlines, self, index, source, transform);
}
}
@@ -1490,15 +1674,21 @@ impl Render for List<Vector> {
/// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work
/// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append.
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, vector: &Vector, transform: DAffine2) {
let filled = vector.style.fill() != &Fill::None;
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, vector_list: &List<Vector>, index: usize, geometry: &Vector, transform: DAffine2) {
let filled = if let Some(graphic_list) = graphic_list_at(vector_list, index, ATTR_FILL) {
graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())
} else if let Some(vector) = vector_list.element(index) {
!matches!(vector.style.fill(), Fill::None)
} else {
false
};
let mut subpaths: Vec<Subpath<_>> = vector.stroke_bezier_paths().collect();
let mut subpaths: Vec<Subpath<_>> = geometry.stroke_bezier_paths().collect();
let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed());
// Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side,
// so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths.
let stroke_width = vector.style.stroke().map_or(0., |stroke| {
let stroke_width = geometry.style.stroke().map_or(0., |stroke| {
if stroke.align.is_not_centered() && all_subpaths_closed {
stroke.weight * 2.
} else {
@@ -1518,7 +1708,7 @@ fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, vector: &Vector, t
targets.push(click_target);
}
for click_target in extend_free_point_targets(vector, transform) {
for click_target in extend_free_point_targets(geometry, transform) {
targets.push(click_target);
}
}

View File

@@ -587,6 +587,12 @@ impl Gradient {
Some(index)
}
/// Builds the affine that places the gradient endpoints at `start` and `end` when applied to canonical gradient space (0, 0) -> (1, 0).
pub fn to_transform(&self) -> DAffine2 {
let direction = self.end - self.start;
DAffine2::from_cols(direction, direction.perp(), self.start)
}
}
// TODO: Eventually remove this migration document upgrade code
@@ -625,3 +631,27 @@ impl core_types::bounds::BoundingBox for GradientStops {
core_types::bounds::RenderBoundingBox::Rectangle([start.min(end), start.max(end)])
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::DVec2;
fn linear_gradient(start: DVec2, end: DVec2) -> Gradient {
Gradient { start, end, ..Default::default() }
}
#[test]
fn to_transform_roundtrip() {
let cases = [(DVec2::ZERO, DVec2::X), (DVec2::new(10., 20.), DVec2::new(50., 30.)), (DVec2::new(-5., -5.), DVec2::new(5., 3.))];
for (start, end) in cases {
let transform = linear_gradient(start, end).to_transform();
let recovered_start = transform.transform_point2(DVec2::ZERO);
let recovered_end = transform.transform_point2(DVec2::X);
assert!((recovered_start - start).length() < 1e-10);
assert!((recovered_end - end).length() < 1e-10);
}
}
}

View File

@@ -580,7 +580,7 @@ impl Stroke {
}
pub fn has_renderable_stroke(&self) -> bool {
self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.)
self.weight > 0.
}
}

View File

@@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU};
use core::hash::{Hash, Hasher};
use core_types::blending::BlendMode;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::list::{Item, List, ListDyn};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, ListDyn};
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
use core_types::transform::{Footprint, Transform};
use core_types::uuid::NodeId;
@@ -1224,13 +1224,22 @@ async fn solidify_stroke<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[
}
// If the original vector has a fill, preserve it as a separate item with the stroke cleared.
let has_fill = !vector.style.fill().is_none();
let has_attr_fill = attributes.keys().any(|k| k == ATTR_FILL);
let has_fill = has_attr_fill || !vector.style.fill().is_none();
let fill_row = has_fill.then(|| {
vector.style.clear_stroke();
Item::from_parts(vector, attributes.clone())
let mut fill_attributes = attributes.clone();
// No stroke remains on the fill row
fill_attributes.remove::<List<Graphic>>(ATTR_STROKE);
Item::from_parts(vector, fill_attributes)
});
let stroke_row = Item::from_parts(solidified_stroke, attributes);
let mut stroke_attributes = attributes;
// Drop the original fill and use the stroke paint to fill the outlined stroke
stroke_attributes.remove::<List<Graphic>>(ATTR_FILL);
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes);
// Ordering based on the paint order. The first item in the `List` is rendered below the second.
match paint_order {