Adopt the cascading "appearance" attribute in place of Vector::stroke and the "fill"/"paint" attributes (#4433)

* Add the appearance model types and attribute constants

* Dual-write the appearance attribute alongside the fill/stroke pair in all paint-writing nodes

* Read paint from the appearance attribute in the renderer, analysis, metadata, and editor, cascading from ancestors

* Retire the fill/stroke attribute pair and the Vector stroke field in favor of the appearance attribute

* Replace the Stroke node's paint order input with the relative chain order of the Fill and Stroke nodes

* Code review fixes

* Re-save demo art

* Stamp coverages in place and fuse the renderer's per-item appearance reads into single walks

* Treat an empty appearance as the undeclared state so padded rows inherit instead of blocking the cascade

* Update demo art

* Treat padded appearance rows as undeclared in the boolean flatten's group recursion
This commit is contained in:
Keavon Chambers
2026-08-14 13:25:23 -07:00
committed by GitHub
parent a034923695
commit d117c3eace
50 changed files with 1448 additions and 595 deletions

View File

@@ -77,10 +77,26 @@ pub const ATTR_POSITION: &str = "position";
/// Gradient stop's `f64` midpoint (implicit default `0.5`, linear), a factor from 0 to 1 across the distance to the next
/// stop, on the `List<Color>` inside a `Gradient`. The final stop's midpoint is ignored if "gradient_cyclic" is false.
pub const ATTR_MIDPOINT: &str = "midpoint";
/// 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";
/// Item's ordered list of paint passes, of type `Appearance`. Earlier coverages paint first, compositing below later ones.
pub const ATTR_APPEARANCE: &str = "appearance";
// TODO: Add a "fill_rule" attribute as a sibling of "paint" on the coverage list (uniform across covers) once a FillRule type ships
/// Coverage's `List<Graphic>` paint (implicit default empty, painting nothing), on the
/// `List<Coverage>` inside an `Appearance`.
pub const ATTR_PAINT: &str = "paint";
/// Stroke coverage's line thickness (`f64`, implicit default `0.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_WEIGHT: &str = "weight";
/// Stroke coverage's `DashPattern` (implicit default empty, a solid line), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_DASH_PATTERN: &str = "dash_pattern";
/// Stroke coverage's dash phase offset distance (`f64`, implicit default `0.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_DASH_OFFSET: &str = "dash_offset";
/// Stroke coverage's `StrokeCap` (implicit default `Butt`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_CAP: &str = "cap";
/// Stroke coverage's `StrokeJoin` (implicit default `Miter`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_JOIN: &str = "join";
/// Stroke coverage's miter limit threshold (`f64`, implicit default `4.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_JOIN_MITER_LIMIT: &str = "join_miter_limit";
/// Stroke coverage's `StrokeAlign` (implicit default `Center`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_ALIGN: &str = "align";
/// Text item's font size in document-space units (`f64`, implicit default `24.`).
pub const ATTR_FONT_SIZE: &str = "font_size";
/// Text item's font, as a `Resource` of the loaded font file.
@@ -678,6 +694,11 @@ impl ItemAttributeValues {
self.0.iter().find_map(|(existing_key, value)| if existing_key == key { Some((**value).as_any()) } else { None })
}
/// Returns an iterator over key and type-erased value pairs of all stored attributes, in insertion order.
pub fn iter_any(&self) -> impl Iterator<Item = (&str, &dyn std::any::Any)> {
self.0.iter().map(|(key, value)| (key.as_str(), (**value).as_any()))
}
/// Returns a debug-formatted string representation of the attribute value for the given key, if it exists.
/// The `overrides` function can provide custom formatting for specific type.
pub fn display_value(&self, key: &str, overrides: fn(&dyn std::any::Any) -> Option<String>) -> Option<String> {

View File

@@ -0,0 +1,387 @@
//! The appearance model: an ordered list of paint passes ("coverages") stored in the `ATTR_APPEARANCE` attribute.
//! Data uniform across all covers (the paint) rides the outer `List<Coverage>` so columnar presence holds,
//! while cover-specific data rides the inner `Item<Cover>`, reusing `ATTR_TRANSFORM` for the stroke-authoring space.
use crate::graphic::{Graphic, is_paint_present};
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_ALIGN, ATTR_APPEARANCE, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_PAINT, ATTR_TRANSFORM, ATTR_WEIGHT, Item, List};
use vector_types::vector::style::{DashPattern, Stroke};
/// The geometry-to-region operator a coverage applies before painting:
/// the interior of the geometry (fill) or the region swept along its outline (stroke).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)]
pub enum Cover {
#[default]
Fill,
Stroke,
}
impl std::fmt::Display for Cover {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fill => write!(f, "Fill"),
Self::Stroke => write!(f, "Stroke"),
}
}
}
/// One paint pass of an [`Appearance`]: a [`Cover`] plus its cover-specific parameters, carried as
/// attributes on the inner item. Attributes for stroke parameters are ignored on fill coverages.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Coverage(pub Item<Cover>);
/// An item's ordered list of paint passes, stored in the `ATTR_APPEARANCE` attribute cell.
/// Earlier coverages paint first, compositing below later ones. Each row's paint is the
/// `ATTR_PAINT` attribute beside it.
///
/// The empty appearance is its elided attribute default form, and the state in which it is
/// replaced by an inherited appearance from an outer level of the cascade.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Appearance(pub List<Coverage>);
/// Where a newly inserted coverage lands in the paint order when no same-cover coverage exists to replace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoverPlacement {
/// The front of the list, painting first (below every existing pass).
Below,
/// The back of the list, painting last (above every existing pass).
Above,
}
impl Coverage {
/// Creates a fill coverage with no parameters beyond its cover.
pub fn new_fill() -> Self {
Self(Item::new_from_element(Cover::Fill))
}
/// Creates a stroke coverage, stamping only the parameters that differ from their implicit defaults.
pub fn new_stroke(stroke: &Stroke) -> Self {
let defaults = Stroke::default();
let mut item = Item::new_from_element(Cover::Stroke);
if stroke.weight != defaults.weight {
item.set_attribute(ATTR_WEIGHT, stroke.weight);
}
if !stroke.dash_lengths.is_empty() {
item.set_attribute(ATTR_DASH_PATTERN, DashPattern::from(stroke.dash_lengths.clone()));
}
if stroke.dash_offset != defaults.dash_offset {
item.set_attribute(ATTR_DASH_OFFSET, stroke.dash_offset);
}
if stroke.cap != defaults.cap {
item.set_attribute(ATTR_CAP, stroke.cap);
}
if stroke.join != defaults.join {
item.set_attribute(ATTR_JOIN, stroke.join);
}
if stroke.join_miter_limit != defaults.join_miter_limit {
item.set_attribute(ATTR_JOIN_MITER_LIMIT, stroke.join_miter_limit);
}
if stroke.align != defaults.align {
item.set_attribute(ATTR_ALIGN, stroke.align);
}
if stroke.transform != defaults.transform {
item.set_attribute(ATTR_TRANSFORM, stroke.transform);
}
Self(item)
}
/// This coverage's cover.
pub fn cover(&self) -> Cover {
*self.0.element()
}
/// Extracts the stroke parameters into a [`Stroke`], falling back to the default for any absent attribute.
/// Dash lengths are clamped to non-negative, matching what rendering accepts.
pub fn stroke_params(&self) -> Stroke {
// A single walk of the attribute pairs instead of one keyed scan per parameter, since this runs per item per render pass
let mut stroke = Stroke::default();
for (key, value) in self.0.attributes().iter_any() {
match key {
ATTR_WEIGHT => stroke.weight = value.downcast_ref().copied().unwrap_or(stroke.weight),
ATTR_DASH_PATTERN => stroke.dash_lengths = value.downcast_ref::<DashPattern>().map(DashPattern::clamped_lengths).unwrap_or(stroke.dash_lengths),
ATTR_DASH_OFFSET => stroke.dash_offset = value.downcast_ref().copied().unwrap_or(stroke.dash_offset),
ATTR_CAP => stroke.cap = value.downcast_ref().copied().unwrap_or(stroke.cap),
ATTR_JOIN => stroke.join = value.downcast_ref().copied().unwrap_or(stroke.join),
ATTR_JOIN_MITER_LIMIT => stroke.join_miter_limit = value.downcast_ref().copied().unwrap_or(stroke.join_miter_limit),
ATTR_ALIGN => stroke.align = value.downcast_ref().copied().unwrap_or(stroke.align),
ATTR_TRANSFORM => stroke.transform = value.downcast_ref().copied().unwrap_or(stroke.transform),
_ => {}
}
}
stroke
}
}
/// Builds an appearance row, eliding the paint attribute when it draws nothing.
fn cover_row(coverage: Coverage, paint: List<Graphic>) -> Item<Coverage> {
let mut row = Item::new_from_element(coverage);
if is_paint_present(&paint) {
row.set_attribute(ATTR_PAINT, paint);
}
row
}
impl Appearance {
/// Creates an appearance holding a single coverage with the given paint.
pub fn new_single(coverage: Coverage, paint: List<Graphic>) -> Self {
Self(List::new_from_item(cover_row(coverage, paint)))
}
/// The number of coverages in this appearance.
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether this appearance holds no coverages, the undeclared state that defers to the cascade.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// This appearance if it declares any coverages, or `None` for the undeclared (empty) state.
pub fn declared(&self) -> Option<&Self> {
(!self.is_empty()).then_some(self)
}
/// Resolves the cascade for one item: its own declared appearance wins, while an undeclared
/// (absent or empty) cell inherits the nearest ancestor's declared appearance.
pub fn cascade<'a>(own: Option<&'a Self>, inherited: Option<&'a Self>) -> Option<&'a Self> {
own.and_then(Self::declared).or(inherited)
}
/// Iterates the coverages in paint order.
pub fn covers(&self) -> impl Iterator<Item = &Coverage> {
self.0.iter_element_values()
}
/// The coverage at the given index in paint order.
pub fn cover_at(&self, index: usize) -> Option<&Coverage> {
self.0.element(index)
}
/// The paint of the coverage at the given index, or `None` if the paint attribute is absent.
pub fn paint_at(&self, index: usize) -> Option<&List<Graphic>> {
self.0.attribute::<List<Graphic>>(ATTR_PAINT, index)
}
/// The index of the first coverage of the given cover in paint order.
pub fn first_index_of(&self, cover: Cover) -> Option<usize> {
self.covers().position(|coverage| coverage.cover() == cover)
}
/// The first coverage of the given cover in paint order.
pub fn first_coverage_of(&self, cover: Cover) -> Option<&Coverage> {
self.first_index_of(cover).and_then(|index| self.cover_at(index))
}
/// The paint of the first coverage of the given cover, filtered to paint that draws something.
pub fn first_paint_of(&self, cover: Cover) -> Option<&List<Graphic>> {
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| is_paint_present(paint))
}
/// Iterates the coverages in paint order together with their paint, which is `None` when absent or drawing nothing.
pub fn covers_with_paints(&self) -> impl Iterator<Item = (&Coverage, Option<&List<Graphic>>)> {
self.covers()
.enumerate()
.map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| is_paint_present(paint))))
}
/// Gathers the renderer's per-item reads in one walk of the coverage list.
pub fn fill_and_stroke(&self) -> FillAndStroke<'_> {
let mut first_fill = None;
let mut first_stroke = None;
for (index, coverage) in self.covers().enumerate() {
match coverage.cover() {
Cover::Fill if first_fill.is_none() => first_fill = Some(index),
Cover::Stroke if first_stroke.is_none() => first_stroke = Some((index, coverage)),
_ => {}
}
}
let painted = |index| self.paint_at(index).filter(|paint| is_paint_present(paint));
FillAndStroke {
stroke: first_stroke.map(|(_, coverage)| coverage.stroke_params()),
fill_paint: first_fill.and_then(painted),
stroke_paint: first_stroke.and_then(|(index, _)| painted(index)),
stroke_below: first_stroke.zip(first_fill).is_some_and(|((stroke_index, _), fill_index)| stroke_index < fill_index),
}
}
/// Whether any coverage of the given cover exists, regardless of whether its paint draws anything.
pub fn has_cover(&self, cover: Cover) -> bool {
self.first_index_of(cover).is_some()
}
/// Whether any coverage of the given cover has paint that draws something, i.e. paint that is
/// present and not empty. A coverage whose paint is [`Graphic::None`] exists but paints nothing.
pub fn has_painted_cover(&self, cover: Cover) -> bool {
self.covers()
.enumerate()
.any(|(index, coverage)| coverage.cover() == cover && self.paint_at(index).is_some_and(is_paint_present))
}
/// Replaces the first coverage of the incoming cover in place (keeping its position in the paint order),
/// or inserts a new row at the requested end of the paint order if none exists.
pub fn replace_or_insert(&mut self, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
if let Some(index) = self.first_index_of(coverage.cover()) {
if let Some(element) = self.0.element_mut(index) {
*element = coverage;
}
self.0.set_attribute(ATTR_PAINT, index, paint);
return;
}
let row = cover_row(coverage, paint);
match placement {
CoverPlacement::Above => self.0.push(row),
CoverPlacement::Below => {
let mut reordered = List::new_from_item(row);
reordered.extend(std::mem::take(&mut self.0));
self.0 = reordered;
}
}
}
/// Sets the paint of the first coverage of the given cover, leaving its other parameters untouched.
/// Returns `false` without changing anything if no coverage of that cover exists.
pub fn set_paint_of(&mut self, cover: Cover, paint: List<Graphic>) -> bool {
let Some(index) = self.first_index_of(cover) else { return false };
self.0.set_attribute(ATTR_PAINT, index, paint);
true
}
/// Discards every coverage that is not of the given cover, preserving the survivors' paint order.
pub fn retain_cover(&mut self, cover: Cover) {
self.0 = std::mem::take(&mut self.0).into_iter().filter(|row| row.element().cover() == cover).collect();
}
}
/// The first fill and stroke of an appearance in the form rendering consumes: the stroke's parameters,
/// each cover's first paint (filtered to paint that draws something), and their relative paint order.
#[derive(Debug, Default)]
pub struct FillAndStroke<'a> {
pub stroke: Option<Stroke>,
pub fill_paint: Option<&'a List<Graphic>>,
pub stroke_paint: Option<&'a List<Graphic>>,
/// Whether the first stroke coverage sits before the first fill in the paint order, painting below it.
pub stroke_below: bool,
}
/// Stamps a coverage into the item's `ATTR_APPEARANCE` cell, creating the attribute if absent.
/// The coverage replaces the first same-cover one in place, or lands at the placement end of the paint order.
pub fn stamp_coverage<T>(item: &mut Item<T>, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
item.attribute_mut_or_insert_default::<Appearance>(ATTR_APPEARANCE).replace_or_insert(coverage, paint, placement);
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::Color;
use glam::{DAffine2, DVec2};
use vector_types::vector::style::{StrokeAlign, StrokeCap, StrokeJoin};
fn solid_paint(color: Color) -> List<Graphic> {
List::new_from_element(Graphic::Color(List::new_from_element(color)))
}
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
let paint = appearance.paint_at(index)?;
let Some(Graphic::Color(colors)) = paint.element(0) else { return None };
colors.element(0).copied()
}
#[test]
fn stroke_params_survive_the_attribute_round_trip() {
let stroke = Stroke {
weight: 3.,
dash_lengths: vec![4., -2.],
dash_offset: 1.5,
cap: StrokeCap::Round,
join: StrokeJoin::Bevel,
join_miter_limit: 7.,
align: StrokeAlign::Inside,
transform: DAffine2::from_scale(DVec2::new(2., 3.)),
};
let coverage = Coverage::new_stroke(&stroke);
assert_eq!(coverage.cover(), Cover::Stroke);
let extracted = coverage.stroke_params();
assert_eq!(extracted.weight, 3.);
assert_eq!(extracted.dash_lengths, vec![4., 0.], "negative dash lengths should clamp to zero on extraction");
assert_eq!(extracted.dash_offset, 1.5);
assert_eq!(extracted.cap, StrokeCap::Round);
assert_eq!(extracted.join, StrokeJoin::Bevel);
assert_eq!(extracted.join_miter_limit, 7.);
assert_eq!(extracted.align, StrokeAlign::Inside);
assert_eq!(extracted.transform, DAffine2::from_scale(DVec2::new(2., 3.)));
}
#[test]
fn empty_appearance_is_undeclared_and_defers_to_the_cascade() {
let inherited = Appearance::new_single(Coverage::new_fill(), solid_paint(Color::BLACK));
let empty = Appearance::default();
assert!(empty.declared().is_none(), "an empty appearance should be undeclared");
assert!(inherited.declared().is_some(), "an appearance with a coverage should be declared");
assert_eq!(Appearance::cascade(Some(&empty), Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(None, Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&inherited), None), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&empty), None), None);
assert_eq!(Appearance::cascade(None, None), None);
}
#[test]
fn default_valued_stroke_parameters_elide_to_absence() {
let coverage = Coverage::new_stroke(&Stroke::default());
assert_eq!(coverage.0.attributes().keys().count(), 0, "default parameters should stay absent");
assert_eq!(coverage.stroke_params(), Stroke::default(), "absent attributes should read back as the defaults");
let coverage = Coverage::new_stroke(&Stroke::new(2.));
let keys: Vec<_> = coverage.0.attributes().keys().collect();
assert_eq!(keys, vec![ATTR_WEIGHT], "only the non-default weight should be stamped");
assert_eq!(coverage.stroke_params().weight, 2.);
}
#[test]
fn replace_keeps_position_and_the_other_rows_paint() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::BLUE), CoverPlacement::Above);
assert_eq!(appearance.len(), 2, "replacement should not add a row");
assert_eq!(appearance.cover_at(0).map(Coverage::cover), Some(Cover::Fill), "the fill should keep its position");
assert_eq!(paint_color(&appearance, 0), Some(Color::BLUE));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the stroke row's paint should be untouched");
}
#[test]
fn below_insertion_prepends_and_preserves_paint_columns() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Below);
let covers: Vec<_> = appearance.covers().map(Coverage::cover).collect();
assert_eq!(covers, vec![Cover::Fill, Cover::Stroke], "a below-placed fill should paint before the stroke");
assert_eq!(paint_color(&appearance, 0), Some(Color::RED));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the existing row's paint should survive the reorder");
}
#[test]
fn painted_cover_distinguishes_none_paint_from_absence() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), List::new_from_element(Graphic::None), 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");
assert!(!appearance.has_cover(Cover::Stroke));
assert!(!appearance.has_painted_cover(Cover::Stroke));
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
assert!(appearance.has_painted_cover(Cover::Fill));
}
}

View File

@@ -1,13 +1,15 @@
use crate::appearance::{Appearance, Cover, Coverage};
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, NodeIdPath};
use core_types::list::{ATTR_APPEARANCE, ATTR_PAINT, Item, ItemAttributeValues, List, NodeIdPath};
use core_types::math::quad::Quad;
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 dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use raster_types::{CPU, GPU, Raster};
use std::borrow::Cow;
use vector_types::Gradient;
pub use vector_types::Vector;
@@ -119,6 +121,7 @@ pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
&& content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none()
&& content.attribute::<Appearance>(ATTR_APPEARANCE, 0).is_none()
&& content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).is_none()
}
@@ -134,13 +137,14 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
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.
// Whether the parent carries each composed 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_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::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH).is_some();
let parent_appearance = current_graphic_item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).cloned();
let layer_path: NodeIdPath = current_graphic_item.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH);
let current_transform: DAffine2 = current_graphic_item.attribute_cloned_or_default(ATTR_TRANSFORM);
@@ -172,6 +176,14 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
*v *= current_fill;
}
}
// Appearance cascades into each child whose own is undeclared, since a declared child wins wholesale
if let Some(appearance) = &parent_appearance {
for v in sub_list.iter_attribute_values_mut_or_default::<Appearance>(ATTR_APPEARANCE) {
if v.is_empty() {
*v = appearance.clone();
}
}
}
flatten_recursive(output, sub_list, extract_variant);
}
@@ -196,6 +208,11 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
if parent_has_layer_path {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
}
if let Some(appearance) = &parent_appearance
&& item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).is_none()
{
item.set_attribute(ATTR_APPEARANCE, appearance.clone());
}
output.push(item);
}
@@ -216,32 +233,7 @@ pub fn is_paint_present(graphic_list: &List<Graphic>) -> bool {
graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())
}
/// Look up the paint graphics stored under attribute for a vector item, in the canonical `List<Graphic>` form.
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)
// Treat a blank paint attribute as absent so an empty attribute doesn't count as painted
.filter(|graphic_list| is_paint_present(graphic_list))
}
/// Whether the item carries a non-blank canonical `List<Graphic>` paint attribute,
/// checked by borrowing without cloning the renderable list.
pub fn has_paint_at(list: &List<Vector>, index: usize, attribute: &str) -> bool {
list.attribute::<List<Graphic>>(attribute, index).is_some_and(is_paint_present)
}
/// Stores a paint attribute in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute(attributes: &mut ItemAttributeValues, key: &str, paint: impl IntoGraphicList) {
attributes.insert(key, paint.into_graphic_list());
}
/// Stores a paint attribute at a list index in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute_at<T>(list: &mut List<T>, index: usize, key: &str, paint: impl IntoGraphicList) {
list.set_attribute(key, index, paint.into_graphic_list());
}
/// Bake the provided transform into the per-item transforms of the paint graphics stored under the
/// canonical `List<Graphic>` fill and stroke attributes.
/// 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_list_transform<T>(list: &mut List<T>, transform: DAffine2) {
for item_transform in list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
@@ -249,24 +241,26 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
}
}
fn bake_graphic_paint_transform(graphics: &mut List<Graphic>, transform: DAffine2) {
for graphic in graphics.iter_element_values_mut() {
match graphic {
Graphic::None => {}
Graphic::Graphic(list) => bake_list_transform(list, transform),
Graphic::Vector(list) => bake_list_transform(list, transform),
Graphic::RasterCPU(list) => bake_list_transform(list, transform),
Graphic::RasterGPU(list) => bake_list_transform(list, transform),
Graphic::Gradient(list) => bake_list_transform(list, transform),
Graphic::Text(list) => bake_list_transform(list, transform),
Graphic::Color(_) => {}
}
fn bake_graphic_transform(graphic: &mut Graphic, transform: DAffine2) {
match graphic {
Graphic::None => {}
Graphic::Graphic(list) => bake_list_transform(list, transform),
Graphic::Vector(list) => bake_list_transform(list, transform),
Graphic::RasterCPU(list) => bake_list_transform(list, transform),
Graphic::RasterGPU(list) => bake_list_transform(list, transform),
Graphic::Gradient(list) => bake_list_transform(list, transform),
Graphic::Text(list) => bake_list_transform(list, transform),
Graphic::Color(_) => {}
}
}
for paint_key in [ATTR_FILL, ATTR_STROKE] {
if let Some(graphics) = attributes.get_mut::<List<Graphic>>(paint_key) {
bake_graphic_paint_transform(graphics, transform);
if let Some(appearance) = attributes.get_mut::<Appearance>(ATTR_APPEARANCE)
&& let Some(paints) = appearance.0.iter_attribute_values_mut::<List<Graphic>>(ATTR_PAINT)
{
for paint in paints {
for graphic in paint.iter_element_values_mut() {
bake_graphic_transform(graphic, transform);
}
}
}
}
@@ -448,15 +442,24 @@ impl Graphic {
pub fn can_reduce_to_clip_path(&self) -> bool {
match self {
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.);
let appearance = vector.attribute::<Appearance>(ATTR_APPEARANCE, index);
let fill_opaque_or_absent = graphic_list_at(vector, index, ATTR_FILL).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
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(|paint| paint.element(0).is_none_or(Graphic::is_opaque)))
});
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| graphic_list_at(vector, index, ATTR_STROKE).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
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(|paint| paint.element(0).is_none_or(Graphic::is_fully_transparent)))
});
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
opacity > 1. - f64::EPSILON && fills_opaque_or_absent && strokes_invisible_or_transparent
}),
_ => false,
}
@@ -467,16 +470,27 @@ impl Graphic {
Graphic::None => false,
Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque),
Graphic::Vector(list) => {
let is_paint_opaque_at = |key: &str, index: usize| graphic_list_at(list, index, key).is_some_and(|graphic_list| graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque()));
!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_paint_opaque_at(ATTR_FILL, i);
let stroke_opaque_or_invisible = vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_paint_opaque_at(ATTR_STROKE, i);
opacity >= 1. - f64::EPSILON && fill_opaque && stroke_opaque_or_invisible
let appearance = list.attribute::<Appearance>(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(|paint| paint.element(0).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(|paint| paint.element(0).is_some_and(Graphic::is_opaque)))
});
opacity >= 1. - f64::EPSILON && fill_opaque && strokes_opaque_or_invisible
})
}
Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()),
@@ -490,18 +504,29 @@ impl Graphic {
Graphic::None => true,
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 is_paint_fully_transparent_at =
|key: &str, index: usize| graphic_list_at(list, index, key).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
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_paint_fully_transparent_at(ATTR_FILL, i);
let stroke_invisible = vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_paint_fully_transparent_at(ATTR_STROKE, i);
fill_invisible && stroke_invisible
let appearance = list.attribute::<Appearance>(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(|paint| paint.element(0).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(|paint| paint.element(0).is_none_or(Graphic::is_fully_transparent)))
});
fills_invisible && strokes_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.)),
@@ -530,11 +555,47 @@ 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.
pub fn vector_list_bounding_box(list: &List<Vector>, 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 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::<Appearance>(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];
}
combined_bounds = Some(match combined_bounds {
Some(existing) => Quad::combine_bounds(existing, bounds),
None => bounds,
});
}
match combined_bounds {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
impl BoundingBox for Graphic {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self {
Graphic::None => RenderBoundingBox::None,
Graphic::Vector(list) => list.bounding_box(transform, include_stroke),
Graphic::Vector(list) => vector_list_bounding_box(list, transform, include_stroke),
Graphic::RasterCPU(list) => list.bounding_box(transform, include_stroke),
Graphic::RasterGPU(list) => list.bounding_box(transform, include_stroke),
Graphic::Graphic(list) => list.bounding_box(transform, include_stroke),
@@ -547,7 +608,7 @@ impl BoundingBox for Graphic {
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self {
Graphic::None => RenderBoundingBox::None,
Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke),
Graphic::Vector(vector) => vector_list_bounding_box(vector, transform, include_stroke),
Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
@@ -717,6 +778,33 @@ mod tests {
let flattened: List<Vector> = group.into_flattened_list();
assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5);
}
// 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() {
use core_types::Color;
let solid = |color: Color| List::new_from_element(Graphic::Color(List::new_from_element(color)));
// Declaring an appearance on row 0 forces the column, padding row 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()));
inner.set_attribute(ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), solid(Color::BLACK)));
let mut outer = List::new_from_element(Graphic::Vector(inner));
outer.set_attribute(ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), solid(Color::WHITE)));
let flattened: List<Vector> = outer.into_flattened_list();
let color_of = |index: usize| {
let appearance = flattened.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Some(Graphic::Color(colors)) = appearance.paint_at(0)?.element(0) else { return None };
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");
}
}
#[cfg(test)]

View File

@@ -1,3 +1,4 @@
pub mod appearance;
pub mod artboard;
pub mod graphic;
@@ -7,6 +8,7 @@ pub use raster_types;
pub use vector_types;
// Re-export commonly used types at the crate root
pub use appearance::{Appearance, Cover, CoverPlacement, Coverage, FillAndStroke, stamp_coverage};
pub use artboard::Artboard;
pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector};
@@ -92,10 +94,11 @@ pub mod migrations {
}
/// The legacy `fill` field is intentionally omitted because vector payload migration only
/// recovers editable vector data. The fill/stroke paints are migrated from the node inputs.
/// recovers editable vector data. The stroke parses solely to validate the legacy shape.
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>,
}
@@ -103,6 +106,7 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct VectorData {
#[allow(dead_code)]
pub style: PathStyle,
pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain,
@@ -135,7 +139,6 @@ pub mod migrations {
Ok(match VectorFormat::deserialize(deserializer)? {
VectorFormat::OldVectorData(old) => Some(Vector {
stroke: old.style.stroke,
colinear_manipulators: old.colinear_manipulators,
point_domain: old.point_domain,
segment_domain: old.segment_domain,
@@ -186,10 +189,15 @@ pub mod migrations {
use super::*;
use vector_types::vector::style::Stroke;
/// The legacy `style` payload (including its stroke) must still parse so the untagged format
/// disambiguation succeeds, even though the geometry is all that survives.
#[test]
fn preserves_stroke_from_old_vector_data_style() {
fn recovers_geometry_from_old_vector_data_with_style() {
use core_types::ops::FromAnchorPosition;
let old_vector = legacy::VectorData {
style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) },
point_domain: Vector::from_anchor_position(glam::DVec2::new(3., 4.)).point_domain,
..Default::default()
};
@@ -202,22 +210,26 @@ pub mod migrations {
.as_object_mut()
.unwrap()
.insert("fill".into(), serde_json::to_value(legacy::LegacyFill::default()).unwrap());
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.);
let migrated = migrate_to_optional_vector(value).unwrap().expect("the legacy shape should parse into a vector");
assert_eq!(
migrated.point_domain.positions(),
[glam::DVec2::new(3., 4.)],
"the legacy geometry should survive alongside the discarded style"
);
}
#[test]
fn preserves_stroke_from_current_vector_data() {
let vector = Vector {
stroke: Some(Stroke::new(12.)),
..Default::default()
};
fn recovers_geometry_from_current_vector_data() {
use core_types::ops::FromAnchorPosition;
let vector = Vector::from_anchor_position(glam::DVec2::new(3., 4.));
let value = serde_json::to_value(&vector).unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)]);
}
}
}

View File

@@ -7,7 +7,7 @@ use core_types::{ATTR_GRADIENT_FORM, ATTR_TRANSFORM, Color};
use glam::{DAffine2, DVec2};
use graphic_types::Graphic;
use graphic_types::vector_types::gradient::GradientForm;
use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
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};
@@ -194,7 +194,6 @@ impl RenderExt for Stroke {
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
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 = String::new();
@@ -219,7 +218,7 @@ impl RenderExt for Stroke {
if let Some(stroke_join_miter_limit) = stroke_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{stroke_join_miter_limit}""#);
}
if paint_order.is_some() {
if render_params.stroke_below {
let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#);
}
attributes

View File

@@ -7,7 +7,8 @@ use core_types::bounds::RenderBoundingBox;
use core_types::color::Color;
use core_types::color::SRGBA8;
use core_types::consts::DEFAULT_FONT_SIZE;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath};
use core_types::list::ATTR_APPEARANCE;
use core_types::list::{Item, List, NodeIdPath};
use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Footprint;
@@ -21,13 +22,12 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm};
use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector};
use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Appearance, Artboard, Cover, Coverage, FillAndStroke, Graphic, Vector};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
@@ -224,28 +224,45 @@ pub struct RenderParams {
/// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks
pub alignment_parent_transform: Option<DAffine2>,
pub aligned_strokes: bool,
pub override_paint_order: bool,
/// Paint the stroke below the fill within the same SVG path element
pub stroke_below: 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,
/// The nearest ancestor's appearance, cascading to items that lack their own.
pub inherited_appearance: Option<Appearance>,
}
impl RenderParams {
pub fn for_clipper(&self) -> Self {
Self { for_mask: true, ..*self }
Self { for_mask: true, ..self.clone() }
}
pub fn for_alignment(&self, transform: DAffine2) -> Self {
Self {
alignment_parent_transform: Some(transform),
..*self
..self.clone()
}
}
pub fn for_pattern(&self) -> Self {
Self { inside_pattern: true, ..*self }
// A paint subtree supplies its own styling, so the painted element's appearance must not cascade into it
Self {
inside_pattern: true,
inherited_appearance: None,
..self.clone()
}
}
/// Params for rendering a child item, cascading this item's appearance to descendants lacking their own.
/// Callers only build these when the item carries a declared appearance, so an item without one clones nothing.
pub fn for_child_item(&self, item_appearance: &Appearance) -> Self {
Self {
inherited_appearance: Some(item_appearance.clone()),
..self.clone()
}
}
pub fn to_canvas(&self) -> bool {
@@ -552,12 +569,9 @@ 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 it.
/// Per-layer `ATTR_APPEARANCE` row attribute, exposed so message handlers can read it.
#[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 it.
#[cfg_attr(feature = "serde", serde(skip))]
pub stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
pub backgrounds: Vec<Background>,
}
@@ -581,8 +595,7 @@ impl RenderMetadata {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
appearance_attributes,
backgrounds,
} = self;
upstream_footprints.extend(other.upstream_footprints.iter());
@@ -593,8 +606,7 @@ 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())));
appearance_attributes.extend(other.appearance_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 {
@@ -618,18 +630,19 @@ pub trait Render: BoundingBox + RenderComplexity {
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, _render_params: &RenderParams);
/// The upstream click targets for each layer are collected during the render so that they do not have to be calculated for each click detection.
fn add_upstream_click_targets(&self, _click_targets: &mut Vec<ClickTarget>) {}
/// `inherited_appearance` is the nearest ancestor's appearance, cascading to items that lack their own, mirroring the render cascade.
fn add_upstream_click_targets(&self, _click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {}
/// Like `add_upstream_click_targets` but for visual outlines. `List<Vector>` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry.
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
self.add_upstream_click_targets(outlines);
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
self.add_upstream_click_targets(outlines, inherited_appearance);
}
// TODO: Store all click targets in a vec which contains the AABB, click target, and path
// fn add_click_targets(&self, click_targets: &mut Vec<([DVec2; 2], ClickTarget, Vec<NodeId>)>, current_path: Option<NodeId>) {}
/// Recursively iterate over data in the render (including nested layer stacks upstream of a vector node, in the case of a boolean operation) to collect the footprints, click targets, and vector modify.
fn collect_metadata(&self, _metadata: &mut RenderMetadata, _footprint: Footprint, _element_id: Option<NodeId>) {}
fn collect_metadata(&self, _metadata: &mut RenderMetadata, _footprint: Footprint, _element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {}
fn contains_artboard(&self) -> bool {
false
@@ -665,7 +678,7 @@ impl Render for Graphic {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
if let Some(element_id) = element_id {
match self {
Graphic::None => {}
@@ -729,39 +742,39 @@ impl Render for Graphic {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id),
Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets),
Graphic::Vector(list) => list.add_upstream_click_targets(click_targets),
Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets),
Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets),
Graphic::Color(list) => list.add_upstream_click_targets(click_targets),
Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets),
Graphic::Text(list) => list.add_upstream_click_targets(click_targets),
Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Vector(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Color(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Text(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
}
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines),
Graphic::Vector(list) => list.add_upstream_outline_targets(outlines),
Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines),
Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines),
Graphic::Color(list) => list.add_upstream_outline_targets(outlines),
Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines),
Graphic::Text(list) => list.add_upstream_outline_targets(outlines),
Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Vector(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Color(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Text(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
}
}
@@ -890,7 +903,7 @@ impl Render for List<Artboard> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() {
let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue };
let (location, dimensions, _background, clip) = read_artboard_attributes(self, index);
@@ -912,11 +925,11 @@ impl Render for List<Artboard> {
let mut child_footprint = footprint;
child_footprint.transform *= DAffine2::from_translation(location);
content.collect_metadata(metadata, child_footprint, None);
content.collect_metadata(metadata, child_footprint, None, inherited_appearance);
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() {
let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index);
let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions);
@@ -939,6 +952,12 @@ impl Render for List<Graphic> {
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::<Appearance>(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();
@@ -1008,6 +1027,12 @@ impl Render for List<Graphic> {
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::<Appearance>(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;
@@ -1076,21 +1101,23 @@ impl Render for List<Graphic> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, 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<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(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::<Appearance>(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));
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);
element.collect_metadata(metadata, footprint, None, child_appearance);
}
}
@@ -1101,9 +1128,10 @@ impl Render for List<Graphic> {
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::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_click_targets = Vec::new();
element.add_upstream_click_targets(&mut new_click_targets);
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)
@@ -1112,7 +1140,7 @@ impl Render for List<Graphic> {
all_upstream_click_targets.extend(new_click_targets);
let mut new_outlines = Vec::new();
element.add_upstream_outline_targets(&mut new_outlines);
element.add_upstream_outline_targets(&mut new_outlines, child_appearance);
for outline in new_outlines.iter_mut() {
outline.apply_transform(item_transform)
}
@@ -1124,13 +1152,14 @@ impl Render for List<Graphic> {
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, 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::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_click_targets = Vec::new();
element.add_upstream_click_targets(&mut new_click_targets);
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)
@@ -1140,13 +1169,14 @@ impl Render for List<Graphic> {
}
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, 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::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_outlines = Vec::new();
element.add_upstream_outline_targets(&mut new_outlines);
element.add_upstream_outline_targets(&mut new_outlines, child_appearance);
for outline in new_outlines.iter_mut() {
outline.apply_transform(item_transform)
@@ -1175,16 +1205,29 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
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.);
// The item's own declared appearance wins over one cascading down from an ancestor
let own_appearance = list.attribute::<Appearance>(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: fill_graphic_list,
stroke_paint: stroke_graphic_list,
stroke_below: wants_stroke_below,
} = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
// 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.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
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 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| 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 stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY, stroke_params.as_ref()).unwrap_or(layer_bounds);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
@@ -1196,26 +1239,22 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
path.push_str(bezpath.to_svg().as_str());
}
let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) {
let mask_type = if stroke_params.as_ref().map(|stroke| stroke.align) == Some(StrokeAlign::Inside) {
MaskType::Clip
} else {
MaskType::Mask
};
let fill_graphic_list = graphic_list_at(list, index, ATTR_FILL);
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0));
let stroke_graphic_list = graphic_list_at(list, index, ATTR_STROKE);
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
let fill_graphic = fill_graphic_list.and_then(|l| l.element(0));
let stroke_graphic = stroke_graphic_list.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.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& 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);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.stroke.as_ref().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();
@@ -1223,7 +1262,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
fill_graphic_list,
item_transform,
element_transform,
applied_stroke_transform,
@@ -1235,13 +1274,13 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
let push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone();
cloned_vector.stroke = None;
let cloned_vector = vector.clone();
// 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 mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let black_fill = List::new_from_element(Graphic::Color(List::new_from_element(Color::BLACK)));
mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill));
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
@@ -1255,7 +1294,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path(
render,
face_d,
fill_graphic_list.as_deref(),
fill_graphic_list,
item_transform,
element_transform,
applied_stroke_transform,
@@ -1276,10 +1315,9 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
if let Some((ref id, mask_type, ref vector_item)) = push_id {
let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.stroke.as_ref().unwrap();
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
let (largest_scale, _) = singular_values(applied_stroke_transform);
let inflation = stroke.max_aabb_inflation(true) * largest_scale;
let inflation = stroke_params.as_ref().map(|stroke| stroke.max_aabb_inflation(true)).unwrap_or_default() * largest_scale;
let quad = Quad::from_box(transformed_bounds).inflate(inflation);
let (x, y) = quad.top_left().into();
let (width, height) = (quad.bottom_right() - quad.top_left()).into();
@@ -1301,13 +1339,12 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = override_paint_order;
render_params.stroke_below = override_paint_order || wants_stroke_below;
let stroke_shape_attribute = vector
.stroke
let stroke_shape_attribute = stroke_params
.as_ref()
.map(|stroke| {
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) {
if stroke_graphic_list.is_some() {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
} else {
String::new()
@@ -1316,10 +1353,10 @@ fn render_vector_item_svg(list: &List<Vector>, 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 = vector.stroke.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_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.as_deref()
.as_ref()
.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.
@@ -1338,7 +1375,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
r#" fill="none""#.to_string()
} else {
fill_graphic_list
.as_deref()
.as_ref()
.map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill))
.unwrap_or_else(|| r#" fill="none""#.to_string())
};
@@ -1370,7 +1407,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path(
render,
path.clone(),
fill_graphic_list.as_deref(),
fill_graphic_list,
item_transform,
element_transform,
applied_stroke_transform,
@@ -1425,17 +1462,35 @@ impl Render for List<Vector> {
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let mut clip_masker: Option<List<Vector>> = None;
for index in 0..self.len() {
use graphic_types::vector_types::vector;
// 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()
};
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;
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform));
// The item's own declared appearance wins over one cascading down from an ancestor
let own_appearance = self.attribute::<Appearance>(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: fill_graphic_list,
stroke_paint: stroke_graphic_list,
stroke_below: wants_stroke_below,
} = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
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())
@@ -1458,9 +1513,6 @@ impl Render for List<Vector> {
}
}
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
// 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,
@@ -1471,8 +1523,8 @@ impl Render for List<Vector> {
// 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 = element.stroke.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 stroke = stroke_params.as_ref();
let stroke_fully_transparent = stroke_graphic_list.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());
@@ -1526,10 +1578,9 @@ impl Render for List<Vector> {
}
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
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 Some(fill_graphic) = fill_graphic_list else { return };
for paint_index in 0..fill_graphic.len() {
let Some(paint) = fill_graphic.element(paint_index) else { continue };
@@ -1556,7 +1607,7 @@ impl Render for List<Vector> {
}
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
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);
paint.render_to_vello(scene, multiplied_transform, context, &paint_render_params);
scene.pop_layer();
}
};
@@ -1583,7 +1634,7 @@ impl Render for List<Vector> {
};
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_graphic_list) = stroke_graphic_list else { return };
let Some(stroke) = stroke else { return };
for paint_index in 0..stroke_graphic_list.len() {
@@ -1641,7 +1692,7 @@ impl Render for List<Vector> {
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);
stroke_graphic.render_to_vello(scene, multiplied_transform, context, &paint_render_params);
scene.pop_layer();
}
};
@@ -1657,13 +1708,13 @@ impl Render for List<Vector> {
}
_ => {
if use_layer {
let mut cloned_element = element.clone();
cloned_element.stroke = None;
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);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let black_fill = List::new_from_element(Graphic::Color(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);
@@ -1711,7 +1762,7 @@ impl Render for List<Vector> {
Stroke,
}
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
let order = match wants_stroke_below {
true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
};
@@ -1738,7 +1789,7 @@ impl Render for List<Vector> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>, 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<NodeId, DAffine2> = HashMap::new();
@@ -1751,6 +1802,8 @@ impl Render for List<Vector> {
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(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::<Appearance>(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);
@@ -1766,12 +1819,12 @@ impl Render for List<Vector> {
let item_relative_transform = reference_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform);
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, self, index, source, item_relative_transform);
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.
@@ -1781,11 +1834,8 @@ impl Render for List<Vector> {
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()));
if let Some(appearance) = appearance {
metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone()));
}
}
@@ -1802,7 +1852,8 @@ impl Render for List<Vector> {
if !upstream_nested_layers.is_empty() {
let mut upstream_footprint = footprint;
upstream_footprint.transform *= transform;
upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None);
// 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);
}
}
@@ -1824,25 +1875,27 @@ impl Render for List<Vector> {
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, 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::<Appearance>(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::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
extend_targets_from_vector(click_targets, self, index, vector, transform);
extend_targets_from_vector(click_targets, appearance, vector, transform);
}
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, 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::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
extend_targets_from_vector(outlines, self, index, source, transform);
extend_targets_from_vector(outlines, appearance, source, transform);
}
}
@@ -1855,15 +1908,17 @@ 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_list: &List<Vector>, index: usize, geometry: &Vector, transform: DAffine2) {
let filled = has_paint_at(vector_list, index, ATTR_FILL);
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, appearance: Option<&Appearance>, geometry: &Vector, transform: DAffine2) {
// A coverage whose paint is `Graphic::None` exists but paints nothing, so it does not close subpaths for hit testing
let filled = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill));
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 = geometry.stroke.as_ref().map_or(0., |stroke| {
let stroke_width = appearance.and_then(|appearance| appearance.first_coverage_of(Cover::Stroke)).map_or(0., |coverage| {
let stroke = coverage.stroke_params();
if stroke.align.is_not_centered() && all_subpaths_closed {
stroke.weight * 2.
} else {
@@ -2048,7 +2103,7 @@ impl Render for List<Raster<CPU>> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
@@ -2067,12 +2122,12 @@ impl Render for List<Raster<CPU>> {
// multiply in `transform` (which is the rasterization area, not a layer-stack transform).
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
if !upstream_nested_layers.is_empty() {
upstream_nested_layers.collect_metadata(metadata, footprint, None);
upstream_nested_layers.collect_metadata(metadata, footprint, None, None);
}
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _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);
@@ -2149,7 +2204,7 @@ impl Render for List<Raster<GPU>> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
@@ -2168,12 +2223,12 @@ impl Render for List<Raster<GPU>> {
// multiply in `transform` (which is the rasterization area, not a layer-stack transform).
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
if !upstream_nested_layers.is_empty() {
upstream_nested_layers.collect_metadata(metadata, footprint, None);
upstream_nested_layers.collect_metadata(metadata, footprint, None, None);
}
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _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);
@@ -2432,7 +2487,7 @@ impl Render for List<Gradient> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return };
if self.is_empty() {
return;
@@ -2468,7 +2523,7 @@ impl Render for List<Gradient> {
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _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) {
@@ -2482,7 +2537,7 @@ impl Render for List<Gradient> {
}
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, _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);
@@ -2813,7 +2868,7 @@ impl Render for List<String> {
}
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>, _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)
@@ -2853,7 +2908,7 @@ impl Render for List<String> {
}
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _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);

View File

@@ -715,7 +715,7 @@ impl SmoothPath {
}
}
/// Stepped holds each stop's color the whole way to the next stop, so the ramp jumps at stops and midpoints are inert.
/// Stepped holds each stop's color the whole way to the next stop, so the ramp jumps at stops and midpoints are ignored.
fn stepped_color(stops: &[GradientStop], t: f64, gradient_cyclic: bool) -> Color {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK };

View File

@@ -563,9 +563,9 @@ pub struct HandleId {
impl std::fmt::Display for HandleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.ty {
// I haven't checked if "out" and "in" are reversed, or are accurate translations of the "primary" and "end" terms used in the `HandleType` enum, so this naming is an assumption.
HandleType::Primary => write!(f, "{} out", self.segment.inner()),
HandleType::End => write!(f, "{} in", self.segment.inner()),
// The primary handle sits at the segment's start anchor and the end handle at its end anchor
HandleType::Primary => write!(f, "Segment {} (start handle)", self.segment.inner()),
HandleType::End => write!(f, "Segment {} (end handle)", self.segment.inner()),
}
}
}

View File

@@ -153,6 +153,8 @@ impl StrokeAlign {
}
}
// Backs the control bar's stroke popover radio and legacy document parsing: the relative order
// of the Fill and Stroke nodes in the chain is what actually determines the paint order
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
@@ -235,8 +237,6 @@ pub struct Stroke {
pub align: StrokeAlign,
#[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))]
pub transform: DAffine2,
#[cfg_attr(feature = "serde", serde(default))]
pub paint_order: PaintOrder,
}
impl Stroke {
@@ -250,7 +250,6 @@ impl Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -287,7 +286,6 @@ impl Stroke {
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
trs * skew
},
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
}
}
@@ -402,7 +400,6 @@ impl Default for Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::default(),
}
}
}

View File

@@ -8,7 +8,6 @@ use crate::vector::vector_modification::VectorExt;
use core::borrow::Borrow;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use dyn_any::StaticType;
use glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
@@ -18,8 +17,6 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Vector {
pub stroke: Option<Stroke>,
/// A list of all manipulator groups (referenced in `subpaths`) that have colinear handles (where they're locked at 180° angles from one another).
/// This gets read in `graph_operation_message_handler.rs` by calling `inputs.as_mut_slice()` (search for the string `"Shape does not have both `subpath` and `colinear_manipulators` inputs"` to find it).
pub colinear_manipulators: Vec<[HandleId; 2]>,
@@ -35,7 +32,6 @@ unsafe impl StaticType for Vector {
impl Default for Vector {
fn default() -> Self {
Self {
stroke: Some(Stroke::new(0.)),
colinear_manipulators: Vec::new(),
point_domain: PointDomain::new(),
segment_domain: SegmentDomain::new(),
@@ -49,7 +45,6 @@ impl graphene_hash::CacheHash for Vector {
self.point_domain.cache_hash(state);
self.segment_domain.cache_hash(state);
self.region_domain.cache_hash(state);
self.stroke.cache_hash(state);
self.colinear_manipulators.cache_hash(state);
}
}
@@ -247,10 +242,10 @@ impl Vector {
/// identity (`Inside` = 0, `Outside` = 2×weight): the renderer masks half of a centered double-width
/// stroke, so its AABB matches the unmasked centered stroke's. For open paths the renderer always
/// draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here.
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2, stroke: Option<&Stroke>) -> Option<[DVec2; 2]> {
let path_bounds = self.bounding_box_with_transform(transform);
let Some(stroke) = self.stroke.as_ref() else { return path_bounds };
let Some(stroke) = stroke else { return path_bounds };
// Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall
// back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync.
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed());
@@ -538,40 +533,16 @@ impl Vector {
self.segment_domain.concat(&additional.segment_domain, transform_of_additional, &id_map);
self.region_domain.concat(&additional.region_domain, transform_of_additional, &id_map);
// TODO: properly deal with fills such as gradients
self.stroke = additional.stroke.clone();
self.colinear_manipulators.extend(additional.colinear_manipulators.iter().copied());
}
pub fn set_stroke_transform(&mut self, transform: DAffine2) {
if let Some(stroke) = &mut self.stroke {
stroke.transform = transform;
}
}
}
// The element sees only geometry; stroke inflation is applied at the row level by `vector_list_bounding_box`
// in graphic-types, which can read the appearance attribute the stroke parameters live on.
impl BoundingBox for Vector {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
if !include_stroke {
// Just use the path bounds without stroke
return match self.bounding_box_with_transform(transform) {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
};
}
// Include stroke by adding offset based on stroke width
let stroke = self.stroke.clone();
let stroke_width = stroke.as_ref().map(|s| s.weight()).unwrap_or_default();
let miter_limit = stroke.as_ref().map(|s| s.join_miter_limit).unwrap_or(1.);
let scale = transform.scale_magnitudes();
// Use the full line width to account for different styles of stroke caps
let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
match self.bounding_box_with_transform(transform) {
Some([a, b]) => RenderBoundingBox::Rectangle([a - offset, b + offset]),
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}