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

Reimplemented on the record model in place of upstream d117c3eace.

Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
Keavon Chambers
2026-09-15 14:45:55 +02:00
committed by Dennis Kobert
parent 28e5d1bdc9
commit a41b362fe2
44 changed files with 2087 additions and 1021 deletions

View File

@@ -0,0 +1,417 @@
//! 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.
//!
//! The interior is `'static`: the paint column stores `Graphic<'static>`.
use crate::Graphic;
use crate::markers::ATTR_PAINT;
use core_types::ATTR_TRANSFORM;
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
use vector_types::markers::{ATTR_ALIGN, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_WEIGHT};
use vector_types::vector::style::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, dyn_any::DynAny)]
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, dyn_any::DynAny)]
pub struct Coverage(pub Item<Cover>);
// Item equality ignores attributes, but the stroke parameters live there.
impl PartialEq for Coverage {
fn eq(&self, other: &Self) -> bool {
self.0.element() == other.0.element()
&& self
.0
.attributes()
.iter()
.map(|(key, value)| (key, value.display_string()))
.eq(other.0.attributes().iter().map(|(key, value)| (key, value.display_string())))
}
}
impl CacheHash for Coverage {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.0.element().cache_hash(state);
for (key, value) in self.0.attributes().iter() {
std::hash::Hash::hash(key, state);
std::hash::Hash::hash(&value.display_string(), state);
}
}
}
/// 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, dyn_any::DynAny)]
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, 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 {
let mut stroke = Stroke::default();
for (key, value) in self.0.attributes().iter() {
match key {
ATTR_WEIGHT => stroke.weight = value.as_any().downcast_ref().copied().unwrap_or(stroke.weight),
ATTR_DASH_PATTERN => {
stroke.dash_lengths = value
.as_any()
.downcast_ref::<Vec<f64>>()
.map(|lengths| lengths.iter().map(|length| length.max(0.)).collect())
.unwrap_or(stroke.dash_lengths)
}
ATTR_DASH_OFFSET => stroke.dash_offset = value.as_any().downcast_ref().copied().unwrap_or(stroke.dash_offset),
ATTR_CAP => stroke.cap = value.as_any().downcast_ref().copied().unwrap_or(stroke.cap),
ATTR_JOIN => stroke.join = value.as_any().downcast_ref().copied().unwrap_or(stroke.join),
ATTR_JOIN_MITER_LIMIT => stroke.join_miter_limit = value.as_any().downcast_ref().copied().unwrap_or(stroke.join_miter_limit),
ATTR_ALIGN => stroke.align = value.as_any().downcast_ref().copied().unwrap_or(stroke.align),
ATTR_TRANSFORM => stroke.transform = value.as_any().downcast_ref().copied().unwrap_or(stroke.transform),
_ => {}
}
}
stroke
}
}
/// Builds an appearance row, eliding the paint attribute when it is the default none-paint.
fn cover_row(coverage: Coverage, paint: Graphic<'static>) -> Item<Coverage> {
let mut row = Item::new_from_element(coverage);
if paint != Graphic::default() {
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: Graphic<'static>) -> 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<&Graphic<'static>> {
self.0.attribute::<Graphic<'static>>(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<&Graphic<'static>> {
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| !paint.is_empty())
}
/// 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<&Graphic<'static>>)> {
self.covers().enumerate().map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| !paint.is_empty())))
}
/// 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| !paint.is_empty());
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 the none-paint 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(|paint| !paint.is_empty()))
}
/// 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: Graphic<'static>, 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 => {
self.0 = std::iter::once(row).chain(std::mem::take(&mut self.0)).collect();
}
}
}
/// 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: Graphic<'static>) -> 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 Graphic<'static>>,
pub stroke_paint: Option<&'a Graphic<'static>>,
/// 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: Graphic<'static>, placement: CoverPlacement) {
item.attribute_mut_or_insert_default::<Appearance>(crate::markers::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) -> Graphic<'static> {
Graphic::Color(color)
}
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
let paint = appearance.paint_at(index)?;
let Graphic::Color(color) = paint else { return None };
Some(*color)
}
#[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(), Graphic::default(), CoverPlacement::Above);
assert!(appearance.has_cover(Cover::Fill), "a none-painted coverage still exists");
assert!(!appearance.has_painted_cover(Cover::Fill), "a none-painted coverage draws nothing");
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,6 +1,7 @@
//! The record-crossing glue: group interiors carried between the owned, resident, and persistent regions.
use super::Graphic;
use crate::appearance::Appearance;
use core_types::Color;
use core_types::list::{Item, List};
use raster_types::{CPU, Raster};
@@ -110,12 +111,18 @@ fn attribute_keys(list: &List<Graphic>) -> Vec<String> {
pub(crate) fn map_attribute_groups_to_owned(list: &mut List<Graphic<'_>>) {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_owned(element);
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_owned(element);
}
map_attribute_groups_to_owned(value);
}
map_attribute_groups_to_owned(value);
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_owned(appearance);
}
}
}
@@ -125,15 +132,21 @@ pub(crate) fn map_attribute_groups_to_owned(list: &mut List<Graphic<'_>>) {
pub(crate) fn map_attribute_groups_to_resident(list: &mut List<Graphic<'_>>, arena: &core_types::arena::Arena) -> Option<()> {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let resident = map_groups_to_resident(element, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*element = unsafe { core_types::record::erase_static(resident) };
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let resident = map_groups_to_resident(element, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*element = unsafe { core_types::record::erase_static(resident) };
}
map_attribute_groups_to_resident(value, arena)?;
}
map_attribute_groups_to_resident(value, arena)?;
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_resident(appearance, arena)?;
}
}
Some(())
@@ -145,15 +158,21 @@ pub(crate) fn map_attribute_groups_to_resident(list: &mut List<Graphic<'_>>, are
pub(crate) fn map_attribute_groups_to_persistent(list: &mut List<Graphic<'_>>, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let promoted = map_groups_to_persistent(element, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*element = unsafe { core_types::record::erase_static(promoted) };
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let promoted = map_groups_to_persistent(element, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*element = unsafe { core_types::record::erase_static(promoted) };
}
map_attribute_groups_to_persistent(value, promotion)?;
}
map_attribute_groups_to_persistent(value, promotion)?;
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_persistent(appearance, promotion)?;
}
}
Some(())
@@ -232,13 +251,14 @@ pub(crate) fn list_contains_groups(list: &List<Graphic>) -> bool {
/// Whether any group hides in the list's item attribute values. Only the
/// group-capable columns are scanned: those are the value types the deep field
/// glue is registered for, today `Option<List<Graphic>>` alone. A list with no
/// attribute columns costs nothing, and a column of any other type is decided
/// by its one downcast rather than per value.
/// glue is registered for, `Option<List<Graphic>>` and `Appearance`. A list
/// with no attribute columns costs nothing, and a column of any other type is
/// decided by its one downcast rather than per value.
fn attribute_values_contain_groups(list: &List<Graphic>) -> bool {
list.attribute_keys().any(|key| {
list.iter_attribute_values::<Option<List<Graphic>>>(key)
.is_some_and(|mut values| values.any(|value| value.as_ref().is_some_and(list_contains_groups)))
|| list.iter_attribute_values::<Appearance>(key).is_some_and(|mut values| values.any(appearance_contains_groups))
})
}
@@ -332,12 +352,143 @@ unsafe fn promote_graphic_list(src: *const u8, dst: *mut u8, promotion: &core_ty
Some(())
}
/// The coverage list's attribute keys, owned so the columns can be walked mutably.
fn appearance_attribute_keys(appearance: &Appearance) -> Vec<String> {
appearance.0.attribute_keys().map(str::to_string).collect()
}
/// Whether any group hides in an appearance. Groups can only arrive through the
/// `Graphic`-typed columns of the coverage list, today the paint column alone;
/// a coverage item's own attributes hold plain stroke parameters.
fn appearance_contains_groups(appearance: &Appearance) -> bool {
appearance
.0
.attribute_keys()
.any(|key| appearance.0.iter_attribute_values::<Graphic>(key).is_some_and(|mut values| values.any(graphic_contains_groups)))
}
/// The heap an appearance's paints own, group interiors excluded as
/// [`graphic_retained_heap`] excludes them.
fn appearance_retained_heap(appearance: &Appearance) -> usize {
appearance
.0
.attribute_keys()
.map(|key| appearance.0.iter_attribute_values::<Graphic>(key).map_or(0, |values| values.map(graphic_retained_heap).sum()))
.sum()
}
/// Every group held in an appearance's paint columns, deep-copied to its owned form.
fn map_appearance_groups_to_owned(appearance: &mut Appearance) {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
*value = map_groups_to_owned(value);
}
}
}
/// Every owned group held in an appearance's paint columns, re-parked into
/// `arena`. `None` reports arena exhaustion.
fn map_appearance_groups_to_resident(appearance: &mut Appearance, arena: &core_types::arena::Arena) -> Option<()> {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
let resident = map_groups_to_resident(value, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*value = unsafe { core_types::record::erase_static(resident) };
}
}
Some(())
}
/// The deep copy-out for appearance field values (the appearance marker's bare
/// owned form): content groups leave any paint column in their owned form.
/// Declines (`None`) for group-free content, which already owns everything.
fn deep_clone_appearance(value: &dyn core_types::list::AnyAttributeValue) -> Option<Box<dyn core_types::list::AnyAttributeValue>> {
let appearance = value.as_any().downcast_ref::<Appearance>().expect("an appearance field deep-copies at its own type");
appearance_contains_groups(appearance).then_some(())?;
let mut appearance = appearance.clone();
map_appearance_groups_to_owned(&mut appearance);
Some(Box::new(appearance))
}
/// The deep replay for appearance field values: owned content groups replay
/// into the serving arena before the field re-parks. `Some(None)` declines for
/// group-free content; `None` reports arena exhaustion.
fn deep_repark_appearance(value: &dyn core_types::list::AnyAttributeValue, arena: &core_types::arena::Arena) -> Option<Option<Box<dyn core_types::list::AnyAttributeValue>>> {
let appearance = value.as_any().downcast_ref::<Appearance>().expect("an appearance field replays at its own type");
if !appearance_contains_groups(appearance) {
return Some(None);
}
let mut appearance = appearance.clone();
map_appearance_groups_to_resident(&mut appearance, arena)?;
Some(Some(Box::new(appearance)))
}
/// Every group held in an appearance's paint columns, promoted into the
/// persistent region on the same Cow dispatch the elements take. `None`
/// reports arena exhaustion.
fn map_appearance_groups_to_persistent(appearance: &mut Appearance, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
let promoted = map_groups_to_persistent(value, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*value = unsafe { core_types::record::erase_static(promoted) };
}
}
Some(())
}
/// The promote for appearance fields, retargeting the paint list promote onto
/// the coverage container: the header moves where no group is reachable from
/// any paint column, and otherwise clones into a fresh persistent park, with
/// content groups taking [`map_groups_to_persistent`]'s Cow dispatch one level
/// inside, exactly as [`promote_graphic_list`] dispatches for the paint lists.
///
/// # Safety
/// `src` must point at a live parked appearance field, and `dst` at the field
/// slot the promoted reference is written to.
unsafe fn promote_appearance(src: *const u8, dst: *mut u8, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
// SAFETY: the caller's contract; the slot holds one optional reference.
let Some(appearance) = (unsafe { src.cast::<Option<&Appearance>>().read() }) else {
// SAFETY: as above, into the promoted image's own field slot.
unsafe { dst.cast::<Option<&Appearance>>().write(None) };
return Some(());
};
let retained = appearance_retained_heap(appearance);
if !appearance_contains_groups(appearance) {
// SAFETY: an appearance no group is reachable from owns all of its
// content, and the arena declines a reference that is not a park at the
// appearance's own address and size.
if let Some(moved) = unsafe { promotion.move_park::<Appearance>(std::ptr::from_ref(appearance).cast(), retained) } {
// SAFETY: the move published a live appearance in the persistent region.
unsafe { dst.cast::<Option<&Appearance>>().write(Some(&*moved)) };
return Some(());
}
}
let mut promoted = appearance.clone();
map_appearance_groups_to_persistent(&mut promoted, promotion)?;
let (parked, _) = promotion.persistent().alloc_sized(promoted, retained)?;
// SAFETY: the slot holds one optional reference.
unsafe { dst.cast::<Option<&Appearance>>().write(Some(parked)) };
Some(())
}
const _: () = {
fn register_all() {
core_types::record::register_deep_element_clone::<Graphic>(deep_clone_graphic, deep_repark_graphic);
core_types::record::register_deep_field_value::<Option<List<Graphic>>>(deep_clone_graphic_list, deep_repark_graphic_list);
core_types::record::register_field_promote::<Option<&'static List<Graphic<'static>>>>(promote_graphic_list);
core_types::record::register_element_promote::<Graphic>(promote_graphic);
core_types::record::register_deep_field_value::<Appearance>(deep_clone_appearance, deep_repark_appearance);
core_types::record::register_field_promote::<Option<&'static Appearance>>(promote_appearance);
core_types::record::register_retained_heap::<Appearance>(|value| value.downcast_ref::<Appearance>().map_or(0, appearance_retained_heap));
core_types::record::register_retained_heap::<Graphic>(|value| value.downcast_ref::<Graphic>().map_or(0, graphic_retained_heap));
core_types::record::register_retained_heap::<Vector>(|value| value.downcast_ref::<Vector>().map_or(0, vector_retained_heap));
core_types::record::register_retained_heap::<Raster<CPU>>(|value| value.downcast_ref::<Raster<CPU>>().map_or(0, |raster| raster.data.len() * size_of::<Color>()));
@@ -362,7 +513,7 @@ mod run_tests {
use super::*;
use crate::graphic::test_support::{native_group_paint, unit_square_at};
use crate::graphic::{group_to_legacy_list, map_groups_to_legacy};
use crate::markers::{Fill, Stroke};
use crate::markers::EditorMergedLayers;
use core_types::attribute::Attribute;
use core_types::lane::LaneSource;
use core_types::record::{FieldWrite, RunBuilder, RunView, element_write_hashed};
@@ -374,9 +525,9 @@ mod run_tests {
let vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let group = core_types::record::Group { row: None, content: builder.finish() };
let expected = group_to_legacy_list(&group);
let owned = map_groups_to_owned(&Graphic::Group(group));
@@ -418,9 +569,9 @@ mod run_tests {
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let owned = item.copy_out();
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -431,7 +582,7 @@ mod run_tests {
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let replayed = owned.replay(&arena).expect("the arena holds the replay");
let run = RunView::<Vector>::new(&replayed).expect("the run holds vector elements");
let served = run.attr::<Fill>(0).expect("the fill replays present");
let served = run.attr::<EditorMergedLayers>(0).expect("the fill replays present");
assert_eq!(map_groups_to_legacy(served.element(0).unwrap()), expected);
}
@@ -444,12 +595,12 @@ mod run_tests {
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -479,8 +630,8 @@ mod run_tests {
) -> (core_types::record::Layout, core_types::record::MaterializedSpan, Vec<u64>) {
use core_types::record::{Layout, MaterializedSpan, Promotion, element_write, write_field};
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<Fill>(0)]);
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<EditorMergedLayers>(0)]);
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
let stride = layout.lane_stride();
let mut buffer = vec![0u64; (lanes * stride).div_ceil(8)];
let base = buffer.as_mut_ptr().cast::<u8>();
@@ -503,7 +654,7 @@ mod run_tests {
/// The promoted paint of one lane, at the layout the promote published.
fn promoted_paint<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p List<Graphic<'p>> {
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
let batch = span.batch(persistent, layout).expect("the span resolves in its own region");
// SAFETY: the promote wrote a record of `layout` into every lane.
unsafe { batch.get(lane).rec().read::<Option<&List<Graphic>>>(offset) }.expect("the paint promotes present")
@@ -583,7 +734,7 @@ mod run_tests {
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(4., 4.))));
// SAFETY: the erased native list serves only while `transient` is live; the
// promote under test replaces its borrows.
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(unsafe { core_types::record::erase_static(native) }));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(unsafe { core_types::record::erase_static(native) }));
let (paint, _) = transient.alloc_sized_keyed(paint, 0).unwrap();
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
@@ -593,7 +744,7 @@ mod run_tests {
transient.reset();
let served = promoted_paint(&span, &layout, 0, &persistent);
let held = served.attribute::<Option<List<Graphic>>>(Stroke::NAME, 0).expect("the stroke attribute rides the promoted list");
let held = served.attribute::<Option<List<Graphic>>>("probe:paint", 0).expect("the stroke attribute rides the promoted list");
let held = held.as_ref().expect("the stroke is present");
assert_eq!(
map_groups_to_legacy(held.element(0).unwrap()),
@@ -612,7 +763,7 @@ mod run_tests {
// whose value holds none: neither denies the move.
paint.set_attribute::<f64>("opacity", 0, 0.5);
paint.set_attribute::<Color>("probe:color", 0, Color::BLACK);
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(List::new_from_element(Graphic::Color(Color::WHITE))));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(List::new_from_element(Graphic::Color(Color::WHITE))));
let heap = {
let Some(Graphic::Vector(vector)) = paint.element(0) else { panic!("the paint carries a vector") };
vector.point_domain.positions().as_ptr()
@@ -647,15 +798,15 @@ mod run_tests {
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
// SAFETY: the erased native list serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(unsafe { core_types::record::erase_static(native) }));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(unsafe { core_types::record::erase_static(native) }));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
drop(item);
@@ -670,8 +821,244 @@ mod run_tests {
let value = unsafe { slot.finish() };
// SAFETY: the replay wrote a record of `layout`.
let served = unsafe { layout.rec(&value).read::<Option<&List<Graphic>>>(offset) }.expect("the fill replays present");
let held = served.attribute::<Option<List<Graphic>>>(Stroke::NAME, 0).expect("the stroke attribute rides the replayed list");
let held = served.attribute::<Option<List<Graphic>>>("probe:paint", 0).expect("the stroke attribute rides the replayed list");
let held = held.as_ref().expect("the stroke is present");
assert_eq!(map_groups_to_legacy(held.element(0).unwrap()), expected, "the attribute-held group replayed into the serving arena");
}
use crate::appearance::{Cover, CoverPlacement, Coverage};
use crate::markers::Appearance as AppearanceMarker;
/// A `lanes`-long frame promoted out of `transient` into `persistent`, with
/// `appearance` written into the appearance field of every lane. The frame
/// buffer comes back so it outlives the promote's reads.
fn promote_appearance_field(
appearance: Option<&Appearance>,
lanes: usize,
transient: &core_types::arena::Arena,
persistent: &core_types::arena::Arena,
) -> (core_types::record::Layout, core_types::record::MaterializedSpan, Vec<u64>) {
use core_types::record::{Layout, MaterializedSpan, Promotion, element_write, write_field};
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<AppearanceMarker>(0)]);
let offset = layout.offset_of(AppearanceMarker::NAME, 0).unwrap();
let stride = layout.lane_stride();
let mut buffer = vec![0u64; (lanes * stride).div_ceil(8)];
let base = buffer.as_mut_ptr().cast::<u8>();
let bounds = (base as usize, buffer.len() * 8);
for lane in 0..lanes {
// SAFETY: the frame is this layout's, written at the element slot and
// at the appearance field's own offset.
unsafe {
base.add(lane * stride).cast::<f64>().write(lane as f64);
write_field::<Option<&Appearance>>(base.add(lane * stride), offset, appearance);
}
}
// SAFETY: the frames hold `lanes` live records of `layout`.
let batch = unsafe { core_types::node::RecordBatch::new(base.cast_const(), lanes, &layout) };
let promotion = Promotion::new(transient, bounds, persistent);
// SAFETY: as above.
let span = unsafe { MaterializedSpan::to_persistent(&batch, &promotion) }.expect("the region holds the promote");
(layout, span, buffer)
}
/// The promoted appearance of one lane, at the layout the promote published.
fn promoted_appearance<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p Appearance {
let offset = layout.offset_of(AppearanceMarker::NAME, 0).unwrap();
let batch = span.batch(persistent, layout).expect("the span resolves in its own region");
// SAFETY: the promote wrote a record of `layout` into every lane.
unsafe { batch.get(lane).rec().read::<Option<&Appearance>>(offset) }.expect("the appearance promotes present")
}
#[test]
fn a_promoted_appearance_shares_persistent_interiors() {
let inner_vector = unit_square_at(DVec2::ZERO);
let transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// The interior an upstream promote already published, named by a paint
// the evaluation parked in a coverage's paint column.
let published = native_group_paint(&inner_vector, &persistent);
let Some(Graphic::Group(group)) = published.element(0) else {
panic!("the paint carries a native group")
};
let interior = group.content.lanes().get(0).rec().ptr();
// SAFETY: the group serves only while `persistent` is live, and the
// promote under test replaces every borrow it carries.
let paint = unsafe { core_types::record::erase_static(Graphic::Group(group.clone())) };
let appearance = Appearance::new_single(Coverage::new_fill(), paint);
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Group(group)) = served.paint_at(0) else {
panic!("the promote keeps the group form")
};
assert_eq!(group.content.lanes().get(0).rec().ptr(), interior, "a persistent interior is shared pointer for pointer");
}
#[test]
fn a_group_free_appearance_moves_its_parked_header() {
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Vector(unit_square_at(DVec2::ZERO)));
let heap = {
let Some(Graphic::Vector(vector)) = appearance.paint_at(0) else {
panic!("the paint carries a vector")
};
vector.point_domain.positions().as_ptr()
};
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 2, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.paint_at(0) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
assert!(std::ptr::eq(served, promoted_appearance(&span, &layout, 1, &persistent)), "an appearance two lanes share moves once");
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
assert!(matches!(served.paint_at(0), Some(Graphic::Vector(_))), "the moved appearance survives the transient reset");
}
#[test]
fn an_appearance_whose_paint_holds_groups_never_moves() {
let inner_vector = unit_square_at(DVec2::ZERO);
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
let native = native_group_paint(&inner_vector, &transient);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// SAFETY: the erased native group serves only while `transient` is live;
// the promote under test replaces its borrows.
let paint = unsafe { core_types::record::erase_static(native_group.clone()) };
let appearance = Appearance::new_single(Coverage::new_fill(), paint);
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let moved = std::ptr::eq(std::ptr::from_ref(served).cast::<u8>(), std::ptr::from_ref(appearance).cast::<u8>());
assert!(!moved, "a paint-held group denies the move, so the promote parks a header of its own");
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
let held = served.paint_at(0).expect("the paint rides the promoted appearance");
assert_eq!(map_groups_to_legacy(held), expected, "the paint-held group serves from persistent storage after the reset");
}
#[test]
fn a_group_free_appearance_moves_past_its_stroke_columns() {
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// Stroke parameters on the coverage and a group-free paint: neither denies the move.
let mut appearance = Appearance::new_single(Coverage::new_stroke(&vector_types::vector::style::Stroke::new(2.)), Graphic::Vector(unit_square_at(DVec2::ZERO)));
appearance.replace_or_insert(Coverage::new_fill(), Graphic::Color(Color::WHITE), CoverPlacement::Below);
let heap = {
let Some(Graphic::Vector(vector)) = appearance.paint_at(1) else {
panic!("the stroke paint carries a vector")
};
vector.point_domain.positions().as_ptr()
};
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.paint_at(1) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
assert_eq!(
served.first_coverage_of(Cover::Stroke).map(|coverage| coverage.stroke_params().weight),
Some(2.),
"the stroke parameters survive the move"
);
}
#[test]
fn a_paint_field_whose_appearance_column_holds_groups_never_moves() {
let inner_vector = unit_square_at(DVec2::ZERO);
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// Group-free elements, with the resident group hidden in an item's appearance paint cell.
let native = native_group_paint(&inner_vector, &transient);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// SAFETY: the erased native group serves only while `transient` is live;
// the promote under test replaces its borrows.
let paint_cell = unsafe { core_types::record::erase_static(native_group.clone()) };
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(4., 4.))));
paint.set_attribute(crate::markers::ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), paint_cell));
let (paint, _) = transient.alloc_sized_keyed(paint, 0).unwrap();
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
let served = promoted_paint(&span, &layout, 0, &persistent);
let moved = std::ptr::eq(std::ptr::from_ref(served).cast::<u8>(), std::ptr::from_ref(paint).cast::<u8>());
assert!(!moved, "an appearance-held group denies the move, so the promote parks a header of its own");
transient.reset();
let served = promoted_paint(&span, &layout, 0, &persistent);
let held = served.attribute::<Appearance>(crate::markers::ATTR_APPEARANCE, 0).expect("the appearance rides the promoted list");
let held = held.paint_at(0).expect("the fill coverage keeps its paint");
assert_eq!(map_groups_to_legacy(held), expected, "the appearance-held group serves from persistent storage after the reset");
}
#[test]
fn an_owned_record_deep_copies_appearance_column_groups() {
let inner_vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
let native = native_group_paint(&inner_vector, &source);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// The field's elements are group-free; the group rides an item's appearance
// paint cell, which the shallow read alone would leave borrowing `source`.
// SAFETY: the erased native group serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
let paint_cell = unsafe { core_types::record::erase_static(native_group.clone()) };
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
paint.set_attribute(crate::markers::ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), paint_cell));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
drop(item);
drop(paint);
drop(source);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let frames = core_types::record::test_frames(layout.frame_bytes());
let mut slot = frames.claim(&layout);
owned.replay_into(&mut slot, &arena).expect("the arena holds the replay");
// SAFETY: the replay completes the record in the claimed frame.
let value = unsafe { slot.finish() };
// SAFETY: the replay wrote a record of `layout`.
let served = unsafe { layout.rec(&value).read::<Option<&List<Graphic>>>(offset) }.expect("the field replays present");
let held = served.attribute::<Appearance>(crate::markers::ATTR_APPEARANCE, 0).expect("the appearance rides the replayed list");
let held = held.paint_at(0).expect("the fill coverage keeps its paint");
assert_eq!(map_groups_to_legacy(held), expected, "the appearance-held group replayed into the serving arena");
}
}

View File

@@ -1,8 +1,8 @@
//! The legacy bridge: record-backed groups rebuilt as owned legacy lists.
use super::walk::push_lane_paint_into_interiors;
use super::{Graphic, detable_items};
use crate::markers::{ATTR_FILL, ATTR_STROKE};
use crate::appearance::Appearance;
use crate::markers::{ATTR_APPEARANCE, ATTR_EDITOR_MERGED_LAYERS, ATTR_PAINT};
use core_types::Color;
use core_types::list::{Item, List};
use raster_types::{CPU, GPU, Raster};
@@ -15,11 +15,19 @@ pub fn run_to_list<T: Clone + Send + Sync + dyn_any::StaticTypeSized>(item: &cor
core_types::record::run_to_owned_list(item)
}
/// Converts the group content of the list's paint attribute values to legacy
/// form, so a legacy product owns everything its attributes reach.
/// Converts the group content the list's attribute values reach to legacy
/// form, so a legacy product owns everything its attributes hold: the paint
/// cells inside each appearance, and the merged-layers snapshot.
pub fn map_paint_attrs_to_legacy<T>(list: &mut List<T>) {
for key in [ATTR_FILL, ATTR_STROKE, crate::markers::ATTR_EDITOR_MERGED_LAYERS] {
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(key) else { continue };
if let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(ATTR_APPEARANCE) {
for appearance in appearances {
let Some(cells) = appearance.0.iter_attribute_values_mut::<Graphic>(ATTR_PAINT) else { continue };
for cell in cells {
*cell = map_groups_to_legacy(cell);
}
}
}
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(ATTR_EDITOR_MERGED_LAYERS) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_legacy(element);
@@ -87,7 +95,6 @@ pub fn group_to_legacy_list(group: &core_types::record::Group) -> List<Graphic<'
for element in list.iter_element_values_mut() {
*element = map_groups_to_legacy(element);
}
push_lane_paint_into_interiors(&mut list);
return list;
}
None.or_else(|| run_to_legacy_list::<Vector>(item).map(|list| detable_items(list, Graphic::Vector)))
@@ -102,24 +109,25 @@ pub fn group_to_legacy_list(group: &core_types::record::Group) -> List<Graphic<'
#[cfg(test)]
mod run_tests {
use super::*;
use crate::appearance::Coverage;
use crate::graphic::test_support::{native_group_paint, unit_square_at};
use crate::markers::Fill;
use core_types::attribute::Attribute;
use crate::markers::Appearance as AppearanceMarker;
use core_types::record::{FieldWrite, RunBuilder, element_write_hashed};
use glam::DVec2;
#[test]
fn a_legacy_list_owns_its_paint_attr_content() {
fn a_legacy_list_owns_its_appearance_paint_content() {
let inner_vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
// SAFETY: the erased native list serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint.clone()));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let legacy = run_to_legacy_list::<Vector>(&item).expect("the run lowers to a legacy vector list");
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -127,9 +135,10 @@ mod run_tests {
drop(paint);
drop(source);
let served = legacy.attribute::<Option<List<Graphic>>>(Fill::NAME, 0).expect("the fill attribute rides the list");
let served = served.as_ref().expect("the fill is present");
assert_eq!(served.element(0).unwrap(), &expected);
let served = legacy.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("the appearance rides the list");
let cell = served.paint_at(0).expect("the fill coverage keeps its paint");
let Graphic::Graphic(cell_rows) = cell else { panic!("the paint cell keeps the list form") };
assert_eq!(cell_rows.element(0).unwrap(), &expected);
}
#[test]

View File

@@ -10,13 +10,12 @@ pub(crate) use glue::{list_contains_groups, map_attribute_groups_to_owned, map_a
pub use glue::{map_groups_to_owned, map_groups_to_persistent, map_groups_to_resident};
pub(crate) use legacy::run_to_legacy_list;
pub use legacy::{group_to_legacy_graphic, group_to_legacy_list, map_groups_to_legacy, map_paint_attrs_to_legacy, run_to_list};
pub use paint::{
LanePaint, PaintColumns, PaintOverlay, PaintOverlayColumn, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_graphics, set_paint_attribute, set_paint_attribute_at,
vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
};
pub use paint::{PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, paint_cell_rows, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path};
pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows};
use walk::{group_all_clipped, group_bounding_box, group_is_fully_transparent, group_is_opaque, group_render_complexity};
use crate::appearance::Appearance;
use crate::markers::ATTR_APPEARANCE;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
@@ -195,6 +194,7 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
let current_opacity: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY, 1.);
let current_fill: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
let lane_layer_path: Option<Vec<NodeId>> = current_graphic_item.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH).cloned();
let parent_appearance = current_graphic_item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).cloned();
let (element, attributes) = current_graphic_item.into_parts();
match element {
@@ -222,6 +222,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, lane_layer_path.as_deref());
}
@@ -418,9 +426,9 @@ impl<'e> Graphic<'e> {
}
}
pub fn can_reduce_to_clip_path(&self) -> bool {
pub fn can_reduce_to_clip_path(&self, inherited_appearance: Option<&Appearance>) -> bool {
match self {
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector)),
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector), inherited_appearance),
_ => false,
}
}
@@ -443,9 +451,8 @@ impl<'e> Graphic<'e> {
match self {
Graphic::None => true,
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked
// vector is invisible on its own.
Graphic::Vector(vector) => vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()),
// A bare vector leaf carries no paint or stroke of its own, so it is invisible on its own
Graphic::Vector(_) => true,
Graphic::Color(color) => color.a() == 0.,
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
@@ -674,6 +681,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_items() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
// Declaring an appearance on item 0 forces the attribute, padding item 1 with the empty appearance
let mut inner = List::new();
inner.push(Item::new_from_element(vector_graphic()));
inner.push(Item::new_from_element(vector_graphic()));
inner.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
let mut outer = List::new_from_element(Graphic::Graphic(inner));
outer.set_attribute(ATTR_APPEARANCE, 0, single(Color::WHITE));
let flattened: List<Vector> = outer.into_flattened_list();
let color_of = |index: usize| {
let appearance = flattened.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Graphic::Color(color) = appearance.paint_at(0)? else { return None };
Some(*color)
};
assert_eq!(color_of(0), Some(Color::BLACK), "a declared item should keep its own appearance");
assert_eq!(color_of(1), Some(Color::WHITE), "a padded item should inherit the parent appearance");
}
}
#[cfg(test)]

View File

@@ -1,9 +1,10 @@
//! The paint column level: fill and stroke read as lane columns and threaded down to the elements they reach.
//! The appearance cascade level: the declared appearance read as a lane column and threaded down to the elements it reaches.
use super::{Graphic, IntoGraphicList};
use crate::markers::{ATTR_FILL, ATTR_STROKE, Fill, Stroke};
use super::Graphic;
use crate::appearance::Appearance;
use crate::markers::Appearance as AppearanceMarker;
use core_types::ATTR_TRANSFORM;
use core_types::attribute::{Attribute, Opacity};
use core_types::attribute::Opacity;
use core_types::lane::{LaneColumn, LaneSource};
use core_types::list::{ItemAttributeValues, List};
use glam::DAffine2;
@@ -15,216 +16,87 @@ 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 the marker `A`, in the canonical `List<Graphic>` form.
pub fn paint_graphics<'a, A, S>(source: &'a S, index: usize) -> Option<&'a List<Graphic<'static>>>
where
S: LaneSource,
A: Attribute<Value<'a> = Option<&'a List<Graphic<'static>>>>,
{
source
.attr::<A>(index)
// 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 under the marker `A`,
/// checked by borrowing without cloning the renderable list.
pub fn has_paint<'a, A, S>(source: &'a S, index: usize) -> bool
where
S: LaneSource,
A: Attribute<Value<'a> = Option<&'a List<Graphic<'static>>>>,
{
paint_graphics::<A, S>(source, index).is_some()
}
/// Whether one lane of a vector source draws as a plain clip path: fully
/// opaque, fill absent or opaque, stroke invisible or fully transparent.
pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize) -> bool {
let Some(element) = source.element(index) else { return false };
pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize, inherited_appearance: Option<&Appearance>) -> bool {
if source.element(index).is_none() {
return false;
}
let opacity: f64 = source.attr::<Opacity>(index);
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
let fill_opaque_or_absent = resolved
.fill_paint
.and_then(paint_cell_rows)
.is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let stroke_invisible_or_transparent = resolved.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| resolved
.stroke_paint
.and_then(paint_cell_rows)
.is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
}
/// Whether every lane of a vector source draws as a plain clip path.
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S) -> bool {
(0..source.lane_count()).all(|index| vector_lane_can_reduce_to_clip_path(source, index))
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>) -> bool {
(0..source.lane_count()).all(|index| vector_lane_can_reduce_to_clip_path(source, index, inherited_appearance))
}
/// The paint a lane carries for its interiors, in the reference form
/// [`PaintOverlay`] threads down.
#[derive(Clone, Copy, Default)]
pub struct LanePaint<'a> {
pub fill: Option<&'a List<Graphic<'static>>>,
pub stroke: Option<&'a List<Graphic<'static>>>,
}
impl<'a> LanePaint<'a> {
pub const NONE: Self = Self { fill: None, stroke: None };
pub fn is_present(&self) -> bool {
self.fill.is_some() || self.stroke.is_some()
}
}
/// A source's fill and stroke columns, resolved once for per-lane reads.
/// A source's declared appearance column, resolved once for per-lane reads.
pub struct PaintColumns<'a, S: LaneSource + 'a> {
fill: S::Column<'a, Fill>,
stroke: S::Column<'a, Stroke>,
appearance: S::Column<'a, AppearanceMarker>,
}
impl<'a, S: LaneSource> PaintColumns<'a, S> {
pub fn new(source: &'a S) -> Self {
Self {
fill: source.column::<Fill>(),
stroke: source.column::<Stroke>(),
appearance: source.column::<AppearanceMarker>(),
}
}
/// The lane's present, non-blank paint.
pub fn read(&self, lane: usize) -> LanePaint<'a> {
let present = |value: Option<Option<&'a List<Graphic<'static>>>>| value.flatten().filter(|list| is_paint_present(list));
LanePaint {
fill: present(self.fill.try_get(lane)),
stroke: present(self.stroke.try_get(lane)),
}
/// The lane's own declared appearance; an absent or empty cell is undeclared.
pub fn read_appearance(&self, lane: usize) -> Option<&'a Appearance> {
self.appearance.try_get(lane).flatten().and_then(Appearance::declared)
}
}
/// How far a lane's paint reaches into the element beneath it, mirroring the
/// legacy conversion's paint push: vector interiors directly and vector
/// children of a nested graphic list, one level deep.
/// The appearance cascade threading down the graphic levels: a lane's own
/// declared appearance wins wholesale, an undeclared lane inherits the
/// nearest ancestor's, at any depth. Only a fresh entry (a pattern's own
/// render, or any standalone render root) starts without an inherited one.
#[derive(Clone, Copy)]
pub struct PaintReach<'a> {
pub paint: LanePaint<'a>,
hops: u8,
/// The cascade's resolved appearance: the nearest declared one at or above this lane.
pub appearance: Option<&'a Appearance>,
}
impl<'a> PaintReach<'a> {
pub const NONE: Self = Self { paint: LanePaint::NONE, hops: 0 };
pub const NONE: Self = Self { appearance: None };
/// The lane's effective reach: an inherited paint stays authoritative
/// (lane paint below a push's origin is inert in the legacy model), an
/// absent one reads the lane's own paint.
/// The lane's effective reach: its own declared appearance wins over the inherited one.
pub fn for_lane<S: LaneSource>(self, columns: &PaintColumns<'a, S>, index: usize) -> Self {
match self.paint.is_present() {
true => self,
false => Self { paint: columns.read(index), hops: 2 },
}
}
pub fn applies(&self) -> bool {
self.hops > 0 && self.paint.is_present()
}
/// The reach one graphic nesting level further down.
pub fn nested(self) -> Self {
Self {
paint: self.paint,
hops: self.hops.saturating_sub(1),
}
}
/// The reach entering a group's own graphic run: a spent or absent reach
/// resets so the group's own lane paint applies at its own boundary.
pub fn into_group_graphics(self) -> Self {
match self.applies() {
true => self.nested(),
false => Self::NONE,
appearance: Appearance::cascade(columns.read_appearance(index), self.appearance),
}
}
}
/// A source with a lane's paint forced over its fill and stroke columns,
/// reaching the interiors the legacy conversion's paint push reached.
pub struct PaintOverlay<'a, S> {
inner: &'a S,
paint: LanePaint<'a>,
}
impl<'a, S> PaintOverlay<'a, S> {
pub fn new(inner: &'a S, paint: LanePaint<'a>) -> Self {
Self { inner, paint }
}
}
pub struct PaintOverlayColumn<'a, S: LaneSource + 'a, A: Attribute> {
inner: S::Column<'a, A>,
forced: Option<A::Value<'a>>,
}
impl<'a, S: LaneSource, A: Attribute> LaneColumn<'a, A> for PaintOverlayColumn<'a, S, A> {
fn try_get(&self, lane: usize) -> Option<A::Value<'a>> {
match self.forced {
Some(forced) => Some(forced),
None => self.inner.try_get(lane),
}
}
}
/// The forced value for the marker `A`: the lane paint where `A` is this
/// crate's fill or stroke marker, absent otherwise.
fn forced_paint<'a, A: Attribute>(paint: LanePaint<'a>) -> Option<A::Value<'a>> {
let slot = match A::NAME {
name if name == Fill::NAME => paint.fill,
name if name == Stroke::NAME => paint.stroke,
/// The paint a coverage row's cell holds, in the canonical `List<Graphic>` form the paint
/// renderers consume: this crate's writers carry the list as one graphic cell, and a bare
/// cell of any other form is treated as paint that draws nothing.
pub fn paint_cell_rows<'a>(cell: &'a Graphic<'static>) -> Option<&'a List<Graphic<'static>>> {
match cell {
Graphic::Graphic(list) => Some(list).filter(|list| is_paint_present(list)),
_ => None,
}?;
assert_eq!(
std::any::TypeId::of::<A::Value<'static>>(),
std::any::TypeId::of::<Option<&'static List<Graphic<'static>>>>(),
"attribute `{}` is declared at another value type than this crate's paint form",
A::NAME
);
assert_eq!(
size_of::<A::Value<'a>>(),
size_of::<Option<&'a List<Graphic<'a>>>>(),
"the paint value form must span the marker's value"
);
// SAFETY: the census admits one value type per attribute name, so a `fill` or `stroke` marker carries this crate's `Option<&List<Graphic>>` at the asserted size.
Some(unsafe { std::mem::transmute_copy::<Option<&'a List<Graphic>>, A::Value<'a>>(&Some(slot)) })
}
impl<'a, S: LaneSource> LaneSource for PaintOverlay<'a, S> {
type Element = S::Element;
type Column<'b, A: Attribute>
= PaintOverlayColumn<'b, S, A>
where
Self: 'b;
fn lane_count(&self) -> usize {
self.inner.lane_count()
}
fn element(&self, lane: usize) -> Option<&S::Element> {
self.inner.element(lane)
}
fn column<A: Attribute>(&self) -> PaintOverlayColumn<'_, S, A> {
PaintOverlayColumn {
inner: self.inner.column::<A>(),
forced: forced_paint::<A>(self.paint),
}
}
}
/// Stores a paint attribute in the paint marker's owned form, the only representation paint readers accept.
pub fn set_paint_attribute(attributes: &mut ItemAttributeValues, key: &str, paint: impl IntoGraphicList) {
attributes.insert(key, Some(paint.into_graphic_list()));
}
/// Stores a paint attribute at a list index in the paint marker's owned 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, Some(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 paint
/// graphics inside the item's appearance coverage cells.
pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) {
fn bake_graphic_paint_transform(graphics: &mut List<Graphic>, transform: DAffine2) {
for item_transform in graphics.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
@@ -237,9 +109,13 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
}
}
for paint_key in [ATTR_FILL, ATTR_STROKE] {
if let Some(Some(graphics)) = attributes.get_mut::<Option<List<Graphic>>>(paint_key) {
bake_graphic_paint_transform(graphics, transform);
if let Some(appearance) = attributes.get_mut::<Appearance>(crate::markers::ATTR_APPEARANCE)
&& let Some(cells) = appearance.0.iter_attribute_values_mut::<Graphic>(crate::markers::ATTR_PAINT)
{
for cell in cells {
if let Graphic::Graphic(list) = cell {
bake_graphic_paint_transform(list, transform);
}
}
}
}
@@ -254,22 +130,44 @@ mod run_tests {
use glam::DVec2;
#[test]
fn a_run_serves_the_parked_paint_reference() {
let paint = List::new_from_element(Graphic::Color(Color::BLACK));
fn a_run_serves_the_parked_appearance_reference() {
use crate::appearance::Coverage;
use core_types::lane::LaneSource;
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::BLACK));
let vector = unit_square_at(DVec2::ZERO);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let run = RunView::<Vector>::new(&item).expect("the run holds vector elements");
assert_eq!(run.attr::<Fill>(0), Some(&paint));
assert_eq!(paint_graphics::<Fill, _>(&run, 0), Some(&paint));
assert_eq!(paint_graphics::<Stroke, _>(&run, 0), None);
assert_eq!(run.attr::<AppearanceMarker>(0), Some(&appearance));
let legacy = run_to_legacy_list::<Vector>(&item).expect("the run lowers to a legacy vector list");
assert_eq!(paint_graphics::<Fill, _>(&legacy, 0), paint_graphics::<Fill, _>(&run, 0));
assert_eq!(legacy.attr::<AppearanceMarker>(0), Some(&appearance));
}
#[test]
fn reach_cascades_the_appearance_with_own_wins_arbitration() {
use crate::appearance::Coverage;
use crate::markers::ATTR_APPEARANCE;
let own = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::BLACK));
let inherited = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::WHITE));
// Lane 0 declares its own appearance, lane 1 is padded with the empty (undeclared) one
let mut list: List<Graphic<'static>> = List::new_from_element(Graphic::Vector(Vector::default()));
list.push(core_types::list::Item::new_from_element(Graphic::Vector(Vector::default())));
list.set_attribute(ATTR_APPEARANCE, 0, own.clone());
let columns = PaintColumns::new(&list);
let ancestor = PaintReach { appearance: Some(&inherited) };
assert_eq!(ancestor.for_lane(&columns, 0).appearance, Some(&own), "a declared lane wins over the inherited appearance");
assert_eq!(ancestor.for_lane(&columns, 1).appearance, Some(&inherited), "a padded lane inherits");
assert_eq!(PaintReach::NONE.for_lane(&columns, 1).appearance, None, "no ancestor leaves an undeclared lane bare");
}
}

View File

@@ -1,8 +1,9 @@
//! The native-content walk: vector rows reached through a graphic's own storage, with no legacy conversion.
use super::Graphic;
use super::paint::{LanePaint, PaintColumns, PaintReach, is_paint_present, paint_graphics, set_paint_attribute_at};
use crate::markers::{ATTR_FILL, ATTR_STROKE, Fill};
use super::paint::{PaintColumns, PaintReach};
use crate::appearance::Appearance;
use crate::markers::ATTR_APPEARANCE;
use core_types::attribute::{Attribute, ClippingMask, EditorLayerPath, Opacity, OpacityFill, Transform};
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::lane::LaneSource;
@@ -251,7 +252,7 @@ pub struct VectorRow<'w> {
source: RowSourceRef<'w>,
scale: FlattenScale,
layer_path: Option<&'w [NodeId]>,
paint: LanePaint<'w>,
appearance: Option<&'w Appearance>,
/// The lane of the walk's OWN top level whose subtree produced this row.
/// A leaf at any depth reports the top-level row it descends from, which is
/// the lane whose columns a consumer carries onto it.
@@ -283,16 +284,14 @@ impl VectorRow<'_> {
}
}
/// Whether the built row will carry fill paint: the reaching lane paint,
/// else the row's own.
/// Whether the built row will carry fill paint: a painted fill coverage on
/// the row's resolved appearance.
pub fn has_fill(&self) -> bool {
if self.paint.fill.is_some() {
return true;
}
match &self.source {
RowSourceRef::Lane(level, index) => paint_graphics::<Fill, _>(level, *index).is_some(),
RowSourceRef::Run(run, _, index) => paint_graphics::<Fill, _>(*run, *index).is_some(),
}
let own = match &self.source {
RowSourceRef::Lane(level, index) => level.attr::<crate::markers::Appearance>(*index),
RowSourceRef::Run(run, _, index) => LaneSource::attr::<crate::markers::Appearance>(*run, *index),
};
Appearance::cascade(own, self.appearance).is_some_and(|appearance| appearance.has_painted_cover(crate::appearance::Cover::Fill))
}
/// Builds the row at the end of `out`, applying the reach paint and the
@@ -309,11 +308,6 @@ impl VectorRow<'_> {
out.push(Item::from_parts(vector, run_lane_attributes(item, *lane)));
}
}
for (key, slot) in [(ATTR_FILL, self.paint.fill), (ATTR_STROKE, self.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, index, key, paint.clone());
}
}
if self.scale.has_transform || out.attribute::<DAffine2>(ATTR_TRANSFORM, index).is_some() {
let row_transform: DAffine2 = out.attribute_cloned_or_default(ATTR_TRANSFORM, index);
out.set_attribute(ATTR_TRANSFORM, index, self.scale.transform * row_transform);
@@ -329,6 +323,12 @@ impl VectorRow<'_> {
if let Some(layer_path) = self.layer_path {
out.set_attribute(ATTR_EDITOR_LAYER_PATH, index, layer_path.to_vec());
}
// The cascade's resolved appearance lands on a row whose own is undeclared, since a declared row wins wholesale
if let Some(appearance) = self.appearance
&& out.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared).is_none()
{
out.set_attribute(ATTR_APPEARANCE, index, appearance.clone());
}
}
}
@@ -338,7 +338,7 @@ fn walk_rows_of_run(
item: &core_types::record::GroupItem,
scale: FlattenScale,
layer_path: Option<&[NodeId]>,
paint: LanePaint<'_>,
appearance: Option<&Appearance>,
top_lane: Option<usize>,
visit: &mut dyn FnMut(VectorRow<'_>) -> RowStep,
) -> RowStep {
@@ -350,7 +350,7 @@ fn walk_rows_of_run(
source: RowSourceRef::Run(&run, item, lane),
scale,
layer_path,
paint,
appearance,
top_lane: top_lane.unwrap_or(lane),
}) {
return RowStep::Stop;
@@ -360,9 +360,9 @@ fn walk_rows_of_run(
}
/// Walks a graphic level into its flattened vector rows, matching the legacy
/// push-then-flatten lowering: lane paint threads with [`PaintReach`],
/// ancestor transform, opacity and fill opacity compose down, the containing
/// level's parent layer path overwrites its rows, and non-vector content is
/// flatten lowering: the appearance cascades with [`PaintReach`], ancestor
/// transform, opacity and fill opacity compose down, the containing level's
/// parent layer path overwrites its rows, and non-vector content is
/// discarded. A de-tabled leaf's row is its lane, attributes included.
pub fn walk_vector_rows(level: GraphicLevel<'_>, visit: &mut dyn FnMut(VectorRow<'_>) -> RowStep) {
walk_vector_rows_impl(level, FlattenScale::ROOT, None, PaintReach::NONE, None, visit);
@@ -382,21 +382,13 @@ fn walk_vector_rows_impl<'a>(
if let GraphicLevel::Run(item) = level {
// A vector-typed run is already its rows.
if item.typed_lanes::<Vector>().is_some() {
let paint = match inherited.applies() {
true => inherited.paint,
false => LanePaint::NONE,
};
return walk_rows_of_run(item, scale, parent_layer_path, paint, top_lane, visit);
return walk_rows_of_run(item, scale, parent_layer_path, inherited.appearance, top_lane, visit);
}
}
let columns = PaintColumns::new(&level);
for index in 0..level.lane_count() {
let Some(element) = level.element(index) else { continue };
let reach = inherited.for_lane(&columns, index);
let row_paint = match reach.applies() {
true => reach.paint,
false => LanePaint::NONE,
};
// At the top level this lane IS the row every leaf under it reports.
let row_top = top_lane.unwrap_or(index);
let step = match element {
@@ -404,27 +396,27 @@ fn walk_vector_rows_impl<'a>(
source: RowSourceRef::Lane(level, index),
scale,
layer_path: parent_layer_path,
paint: row_paint,
appearance: reach.appearance,
top_lane: row_top,
}),
Graphic::Graphic(children) => walk_vector_rows_impl(
GraphicLevel::Legacy(children),
scale.composed(&level, index),
level.try_attr::<EditorLayerPath>(index),
reach.nested(),
reach,
Some(row_top),
visit,
),
Graphic::Group(group) => {
let item = &group.content;
if item.typed_lanes::<Vector>().is_some() {
walk_rows_of_run(item, scale.composed(&level, index), level.try_attr::<EditorLayerPath>(index), row_paint, Some(row_top), visit)
walk_rows_of_run(item, scale.composed(&level, index), level.try_attr::<EditorLayerPath>(index), reach.appearance, Some(row_top), visit)
} else if item.typed_lanes::<Graphic>().is_some() {
walk_vector_rows_impl(
GraphicLevel::Run(item),
scale.composed(&level, index),
level.try_attr::<EditorLayerPath>(index),
reach.into_group_graphics(),
reach,
Some(row_top),
visit,
)
@@ -452,26 +444,6 @@ pub fn flatten_vector_rows(level: GraphicLevel<'_>) -> List<Vector> {
out
}
/// The transitional paint placement: a lane-level fill or stroke paint
/// attribute moves onto the vector interiors the legacy paint readers
/// inspect, reaching as far as the pre-flip broadcast did.
pub(in crate::graphic) fn push_lane_paint_into_interiors(list: &mut List<Graphic>) {
for index in 0..list.len() {
for key in [ATTR_FILL, ATTR_STROKE] {
let stored = list.attribute::<Option<List<Graphic>>>(key, index).and_then(|optional| optional.as_ref());
let Some(paint) = stored.filter(|paint| is_paint_present(paint)).cloned() else {
continue;
};
let Some(Graphic::Graphic(children)) = list.element_mut(index) else { continue };
for child in 0..children.len() {
if matches!(children.element(child), Some(Graphic::Vector(_))) {
set_paint_attribute_at(children, child, key, paint.clone());
}
}
}
}
}
/// The count [`map_groups_to_legacy`] would expose through [`Graphic::as_vector`],
/// read from the run's lanes instead of materializing the legacy list. Mirrors
/// [`group_to_legacy_graphic`]'s typed-run path, where `Vector` is tried first.
@@ -544,6 +516,10 @@ mod run_tests {
#[test]
fn the_vector_row_walk_matches_the_legacy_flatten() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
let inner_vector = unit_square_at(DVec2::ZERO);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[], 1).unwrap();
@@ -555,7 +531,7 @@ mod run_tests {
painted.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ONE))));
painted.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(1., 0.)));
painted.set_attribute(core_types::ATTR_TRANSFORM, 1, DAffine2::from_translation(DVec2::new(0., 1.)));
set_paint_attribute_at(&mut painted, 1, ATTR_FILL, List::new_from_element(Graphic::Color(Color::WHITE)));
painted.set_attribute(ATTR_APPEARANCE, 1, single(Color::WHITE));
let mut nested = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
nested.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_scale(DVec2::splat(2.)));
@@ -568,12 +544,12 @@ mod run_tests {
top.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(6., 0.)))));
top.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(5., 5.)));
top.set_attribute(core_types::ATTR_EDITOR_LAYER_PATH, 0, vec![core_types::uuid::NodeId(7)]);
set_paint_attribute_at(&mut top, 0, ATTR_FILL, List::new_from_element(Graphic::Color(Color::BLACK)));
top.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
top.set_attribute(core_types::ATTR_OPACITY, 1, 0.5);
top.set_attribute(core_types::ATTR_TRANSFORM, 2, DAffine2::from_scale(DVec2::splat(3.)));
top.set_attribute(core_types::ATTR_TRANSFORM, 4, DAffine2::from_translation(DVec2::new(0., 7.)));
top.set_attribute(core_types::ATTR_EDITOR_LAYER_PATH, 4, vec![core_types::uuid::NodeId(9)]);
set_paint_attribute_at(&mut top, 4, ATTR_FILL, List::new_from_element(Graphic::Color(Color::WHITE)));
top.set_attribute(ATTR_APPEARANCE, 4, single(Color::WHITE));
let legacy = {
let mut list = List::new();
@@ -581,7 +557,6 @@ mod run_tests {
let (element, attributes) = item.into_parts();
list.push(Item::from_parts(map_groups_to_legacy(&element), attributes));
}
push_lane_paint_into_interiors(&mut list);
list.into_flattened_list::<Vector>()
};
let native = flatten_vector_rows(GraphicLevel::Legacy(&top));
@@ -603,9 +578,9 @@ mod run_tests {
"layer path, row {row}"
);
assert_eq!(
native.attribute::<Option<List<Graphic>>>(ATTR_FILL, row),
legacy.attribute::<Option<List<Graphic>>>(ATTR_FILL, row),
"fill, row {row}"
native.attribute::<Appearance>(ATTR_APPEARANCE, row),
legacy.attribute::<Appearance>(ATTR_APPEARANCE, row),
"appearance, row {row}"
);
}
assert_eq!(native, legacy);
@@ -634,4 +609,33 @@ mod run_tests {
assert_eq!(run.thumbnail_bounding_box(outer, include_stroke), legacy.thumbnail_bounding_box(outer, include_stroke));
}
}
#[test]
fn the_walk_cascades_appearance_like_the_legacy_flatten() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
let mut inner = List::new();
inner.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ZERO))));
inner.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ONE))));
inner.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
let mut top = List::new_from_element(Graphic::Graphic(inner));
top.set_attribute(ATTR_APPEARANCE, 0, single(Color::WHITE));
let walked = flatten_vector_rows(GraphicLevel::Legacy(&top));
let legacy: List<Vector> = top.clone().into_flattened_list();
let color_of = |list: &List<Vector>, index: usize| {
let appearance = list.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Graphic::Color(color) = appearance.paint_at(0)? else { return None };
Some(*color)
};
for list in [&walked, &legacy] {
assert_eq!(color_of(list, 0), Some(Color::BLACK), "a declared row keeps its own appearance");
assert_eq!(color_of(list, 1), Some(Color::WHITE), "an undeclared row inherits the level's appearance");
}
}
}

View File

@@ -1,3 +1,4 @@
pub mod appearance;
pub mod artboard;
pub mod boundary;
pub mod graphic;
@@ -9,9 +10,10 @@ 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};
pub use markers::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE};
pub use markers::{ATTR_APPEARANCE, ATTR_EDITOR_MERGED_LAYERS, ATTR_PAINT};
pub mod migrations {
use crate::Vector;
@@ -99,6 +101,7 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>,
}
@@ -106,6 +109,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,
@@ -138,7 +142,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,
@@ -190,9 +193,12 @@ pub mod migrations {
use vector_types::vector::style::Stroke;
#[test]
fn preserves_stroke_from_old_vector_data_style() {
fn recovers_geometry_from_old_vector_data_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()
};
@@ -207,20 +213,19 @@ pub mod migrations {
.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.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)], "the geometry survives 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

@@ -1,30 +1,104 @@
//! Attribute markers whose value types live in this crate, with their name
//! constants for the string-keyed legacy readers and writers.
//!
//! The list-valued markers may carry native [`Graphic::Group`] content: the
//! registered deep field glue owns it across persistence seams, and legacy
//! products convert it through [`crate::graphic::map_paint_attrs_to_legacy`].
//! The markers may carry native [`Graphic::Group`] content: the registered
//! deep field glue owns it across persistence seams, and legacy products
//! convert it through [`crate::graphic::map_paint_attrs_to_legacy`].
use crate::Graphic;
use core_types::attribute::Attribute;
use core_types::list::List;
core_types::attribute! {
/// Vector graphics object's filled area paint, a graphic list in the canonical paint form.
/// An absent value means no fill.
pub Fill("fill"): Option<&List<Graphic<'static>>>;
/// Vector graphics object's stroke paint, a graphic list in the canonical paint form.
/// An absent value means no stroke paint.
pub Stroke("stroke"): Option<&List<Graphic<'static>>>;
/// Snapshot of the upstream content that fed into a destructive merge (Boolean Operation,
/// Rasterize, etc.), so the editor can still surface click targets for the original child
/// layers after their content has been collapsed.
pub EditorMergedLayers("editor:merged_layers"): Option<&List<Graphic<'static>>>;
}
pub const ATTR_FILL: &str = Fill::NAME;
pub const ATTR_STROKE: &str = Stroke::NAME;
/// The item's ordered list of paint passes. An absent or empty value is the undeclared
/// state that inherits the nearest ancestor's appearance through the cascade, so both
/// read as `None`. The stored form is the bare [`crate::appearance::Appearance`], the
/// shape the appearance writers use, which the `attribute!` macro's optional-reference
/// arm cannot express.
pub struct Appearance;
// SAFETY: `read_erased` produces the bare owned appearance `from_stored` reads, `REPARK`
// re-parks that same form, and the empty appearance collapses to the `None` default at
// every read seam.
unsafe impl Attribute for Appearance {
const NAME: &'static str = "appearance";
type Value<'e> = Option<&'e crate::appearance::Appearance>;
fn from_stored<'a>(stored: &'a dyn std::any::Any) -> Option<Self::Value<'a>> {
stored.downcast_ref::<crate::appearance::Appearance>().map(crate::appearance::Appearance::declared)
}
unsafe fn read_erased(ptr: *const u8) -> Box<dyn core_types::list::AnyAttributeValue> {
Box::new(unsafe { ptr.cast::<Option<&crate::appearance::Appearance>>().read() }.cloned().unwrap_or_default())
}
const REPARK: Option<core_types::list::ReparkFn> = {
unsafe fn repark(value: &dyn core_types::list::AnyAttributeValue, dst: *mut u8, arena: &core_types::arena::Arena) -> Option<()> {
let owned: &crate::appearance::Appearance = value.as_any().downcast_ref().expect("an appearance attribute replays its bare owned clone");
let parked = match owned.is_empty() {
true => None,
false => {
let (parked, _) = arena.alloc(owned.clone())?;
Some(parked)
}
};
// SAFETY: the slot is a live field of this marker's value type.
unsafe { dst.cast::<Option<&crate::appearance::Appearance>>().write(parked) };
Some(())
}
Some(repark)
};
}
core_types::attribute!(@register Appearance);
/// One coverage row's paint, a bare graphic riding the coverage list as a column. Absent
/// or empty paint draws nothing, so both read as `None`; the stored form is the bare
/// [`Graphic`], the shape [`crate::appearance`]'s row writers use.
pub struct Paint;
// SAFETY: as for `Appearance`, at the bare `Graphic` stored form.
unsafe impl Attribute for Paint {
const NAME: &'static str = "paint";
type Value<'e> = Option<&'e Graphic<'static>>;
fn from_stored<'a>(stored: &'a dyn std::any::Any) -> Option<Self::Value<'a>> {
stored.downcast_ref::<Graphic<'static>>().map(|paint| (!paint.is_empty()).then_some(paint))
}
unsafe fn read_erased(ptr: *const u8) -> Box<dyn core_types::list::AnyAttributeValue> {
Box::new(unsafe { ptr.cast::<Option<&Graphic<'static>>>().read() }.cloned().unwrap_or_default())
}
const REPARK: Option<core_types::list::ReparkFn> = {
unsafe fn repark(value: &dyn core_types::list::AnyAttributeValue, dst: *mut u8, arena: &core_types::arena::Arena) -> Option<()> {
let owned: &Graphic<'static> = value.as_any().downcast_ref().expect("a paint attribute replays its bare owned clone");
let parked = match owned.is_empty() {
true => None,
false => {
let (parked, _) = arena.alloc(owned.clone())?;
Some(parked)
}
};
// SAFETY: the slot is a live field of this marker's value type.
unsafe { dst.cast::<Option<&Graphic<'static>>>().write(parked) };
Some(())
}
Some(repark)
};
}
core_types::attribute!(@register Paint);
pub const ATTR_EDITOR_MERGED_LAYERS: &str = EditorMergedLayers::NAME;
pub const ATTR_APPEARANCE: &str = Appearance::NAME;
pub const ATTR_PAINT: &str = Paint::NAME;
#[cfg(test)]
mod tests {
@@ -34,25 +108,13 @@ mod tests {
#[test]
fn the_census_carries_this_crates_names() {
for name in ["fill", "stroke", "editor:merged_layers"] {
assert_eq!(info(name).unwrap().value_type, TypeId::of::<Option<&'static List<Graphic>>>());
}
assert_eq!(info("editor:merged_layers").unwrap().value_type, TypeId::of::<Option<&'static List<Graphic>>>());
assert_eq!(info("appearance").unwrap().value_type, TypeId::of::<Option<&'static crate::appearance::Appearance>>());
assert_eq!(info("paint").unwrap().value_type, TypeId::of::<Option<&'static Graphic>>());
}
#[test]
fn an_absent_paint_defaults_to_none() {
assert_eq!(<Fill as Attribute>::default(), None);
}
#[test]
fn a_paint_marker_reads_back_what_the_paint_writer_stored() {
use core_types::lane::LaneSource;
let paint = List::new_from_element(Graphic::default());
let mut list = List::new_from_element(Graphic::default());
crate::graphic::set_paint_attribute_at(&mut list, 0, ATTR_FILL, paint.clone());
assert_eq!(list.attr::<Fill>(0), Some(&paint));
assert_eq!(list.attr::<Stroke>(0), None);
assert_eq!(<Paint as Attribute>::default(), None);
}
}

View File

@@ -185,3 +185,60 @@ impl Display for BlendMode {
}
}
}
/// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller.
pub fn apply_blend_mode(foreground: crate::color::Color, background: crate::color::Color, blend_mode: BlendMode) -> crate::color::Color {
use crate::color::Color;
match blend_mode {
// Normal group
BlendMode::Normal => background.blend_rgb(foreground, Color::blend_normal),
// Darken group
BlendMode::Darken => background.blend_rgb(foreground, Color::blend_darken),
BlendMode::Multiply => background.blend_rgb(foreground, Color::blend_multiply),
BlendMode::ColorBurn => background.blend_rgb(foreground, Color::blend_color_burn),
BlendMode::LinearBurn => background.blend_rgb(foreground, Color::blend_linear_burn),
BlendMode::DarkerColor => background.blend_darker_color(foreground),
// Lighten group
BlendMode::Lighten => background.blend_rgb(foreground, Color::blend_lighten),
BlendMode::Screen => background.blend_rgb(foreground, Color::blend_screen),
BlendMode::ColorDodge => background.blend_rgb(foreground, Color::blend_color_dodge),
BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge),
BlendMode::LighterColor => background.blend_lighter_color(foreground),
// Contrast group
BlendMode::Overlay => background.blend_rgb(foreground, Color::blend_overlay),
BlendMode::SoftLight => background.blend_rgb(foreground, Color::blend_softlight),
BlendMode::HardLight => background.blend_rgb(foreground, Color::blend_hardlight),
BlendMode::VividLight => background.blend_rgb(foreground, Color::blend_vivid_light),
BlendMode::LinearLight => background.blend_rgb(foreground, Color::blend_linear_light),
BlendMode::PinLight => background.blend_rgb(foreground, Color::blend_pin_light),
BlendMode::HardMix => background.blend_rgb(foreground, Color::blend_hard_mix),
// Inversion group
BlendMode::Difference => background.blend_rgb(foreground, Color::blend_difference),
BlendMode::Exclusion => background.blend_rgb(foreground, Color::blend_exclusion),
BlendMode::Subtract => background.blend_rgb(foreground, Color::blend_subtract),
BlendMode::Divide => background.blend_rgb(foreground, Color::blend_divide),
// Component group
BlendMode::Hue => background.blend_hue(foreground),
BlendMode::Saturation => background.blend_saturation(foreground),
BlendMode::Color => background.blend_color(foreground),
BlendMode::Luminosity => background.blend_luminosity(foreground),
// The alpha-only utility modes mix no color, so the foreground passes through for the caller to composite
BlendMode::Erase | BlendMode::Restore | BlendMode::MultiplyAlpha => foreground,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
#[test]
fn overlay_is_hard_light_with_swapped_operands() {
let a = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.);
let b = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.);
let overlay = apply_blend_mode(a, b, BlendMode::Overlay);
let swapped_hard_light = apply_blend_mode(b, a, BlendMode::HardLight);
assert_eq!(overlay, swapped_hard_light);
}
}

View File

@@ -750,6 +750,11 @@ impl Color {
}
}
/// Per-channel "Overlay" blend: hard light with its operands swapped.
pub fn blend_overlay(c_b: f32, c_s: f32) -> f32 {
Self::blend_hardlight(c_s, c_b)
}
/// Per-channel "Hard Light" blend.
pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {

View File

@@ -1,4 +1,6 @@
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible};
use crate::renderer::{
ClearGuardPlacement, RenderParams, composite_paint_colors, format_transform_matrix, gradient_placement, paint_faded_samples, paint_lane_opacity, spread_adjusted_samples, transform_is_invertible,
};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::Color;
use core_types::attribute::Transform;
@@ -52,13 +54,13 @@ pub trait RenderExt {
) -> Self::Output;
}
/// The color paint attribute over any color lane source.
pub fn render_color_paint<S: core_types::lane::LaneSource<Element = Color>>(source: &S, target: PaintTarget) -> String {
let Some(color) = source.element(0) else {
/// The color paint attribute for a composited paint color.
pub fn render_color_paint(color: Option<Color>, target: PaintTarget) -> String {
let Some(color) = color else {
return format!(r#" {}="none""#, target.paint_attr());
};
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex());
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.);
}
@@ -76,10 +78,10 @@ impl RenderExt for List<Color> {
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
render_color_paint(self, target)
render_color_paint(composite_paint_colors(self, |color| Some(*color), render_params.for_mask), target)
}
}
@@ -94,16 +96,16 @@ impl RenderExt for List<Gradient> {
element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
render_gradient_paint(self, svg_defs, item_transform, element_transform)
render_gradient_paint(self, svg_defs, item_transform, element_transform, render_params.for_mask)
}
}
/// Adds the gradient def through mutating `svg_defs`, returning the gradient
/// ID, over any gradient lane source.
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 {
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2, for_mask: bool) -> u64 {
let mut stop = String::new();
{
@@ -113,6 +115,7 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>
let settings = GradientSettings::from_lane_attributes(source, 0);
let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let samples = paint_faded_samples(samples, paint_lane_opacity(source, 0, for_mask));
for (position, color, original_midpoint) in samples {
stop.push_str("<stop");
@@ -209,7 +212,7 @@ 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);
let paint_order = render_params.stroke_below.then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = String::new();
@@ -258,9 +261,13 @@ impl RenderExt for List<Graphic<'_>> {
let paint_attr = target.paint_attr();
match fill_graphic {
Some(Graphic::Color(color)) => render_color_paint(&core_types::lane::LeafLane::new(self, 0, color), target),
Some(Graphic::Color(_)) => {
// The whole color stack collapses to the one composited color the fast path emits
let composited = composite_paint_colors(self, |graphic| if let Graphic::Color(color) = graphic { Some(*color) } else { None }, render_params.for_mask);
render_color_paint(composited, target)
}
Some(Graphic::Gradient(gradient)) => {
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform);
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform, render_params.for_mask);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::None) => format!(r#" {paint_attr}="none""#),

View File

@@ -23,17 +23,16 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{
PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::appearance::{Appearance, Coverage};
use graphic_types::graphic::{PaintColumns, PaintReach, is_paint_present, paint_cell_rows, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings};
use graphic_types::vector_types::markers::GradientForm as GradientFormAttr;
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::{ATTR_FILL, Artboard, Graphic, Vector};
use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
@@ -209,6 +208,98 @@ pub struct RenderContext {
pub resource_overrides: Vec<(peniko::ImageBrush, Texture)>,
}
/// The single black-fill appearance a mask clone paints with, at full alpha so the mask fully covers the interior.
fn black_fill_appearance() -> Appearance {
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(List::new_from_element(Graphic::Color(Color::BLACK))))
}
/// The alpha multiplier a paint row's opacity attributes apply when it serves as a paint.
/// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it.
pub(crate) fn paint_row_opacity<T>(list: &List<T>, index: usize, for_mask: bool) -> f32 {
let opacity_fill = if for_mask {
1.
} else {
list.attribute_cloned_or::<f64>(core_types::ATTR_OPACITY_FILL, index, 1.)
};
(list.attribute_cloned_or::<f64>(core_types::ATTR_OPACITY, index, 1.) * opacity_fill) as f32
}
/// [`paint_row_opacity`] over a lane source, for the gradient paths that hold one.
pub(crate) fn paint_lane_opacity<S: LaneSource>(source: &S, index: usize, for_mask: bool) -> f32 {
let opacity_fill: f64 = if for_mask { 1. } else { source.attr::<OpacityFill>(index) };
(source.attr::<Opacity>(index) * opacity_fill) as f32
}
/// Fades a gradient's renderer samples by the paint row's own opacity, applied after
/// interpolation so the ramp's interpolation space is the one the stops were sampled in.
/// Transparent `Clear` guards are unaffected, their alpha already being zero.
pub(crate) fn paint_faded_samples(samples: GradientSamples, paint_opacity: f32) -> GradientSamples {
if paint_opacity >= 1. {
return samples;
}
samples
.into_iter()
.map(|(position, color, midpoint)| (position, color.with_alpha(color.a() * paint_opacity), midpoint))
.collect()
}
/// Composites one paint color over the stack beneath it, mixing by the blend mode and then source-over in straight alpha.
fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Color {
let (over_alpha, under_alpha) = (over.a(), under.a());
// These modes only move the backdrop's alpha, leaving its color alone
match blend_mode {
BlendMode::Erase => return under.with_alpha((under_alpha - over_alpha).clamp(0., 1.)),
BlendMode::Restore => return under.with_alpha((under_alpha + over_alpha).clamp(0., 1.)),
BlendMode::MultiplyAlpha => return under.with_alpha(under_alpha * over_alpha),
_ => {}
}
let result_alpha = over_alpha + under_alpha * (1. - over_alpha);
if result_alpha <= 0. {
return Color::TRANSPARENT;
}
// The blend formulas read their backdrop premultiplied
let premultiplied_under = Color::from_rgbaf32_unchecked(under.r() * under_alpha, under.g() * under_alpha, under.b() * under_alpha, under_alpha);
let mixed = core_types::blending::apply_blend_mode(over, premultiplied_under, blend_mode);
// The mode only mixes where the backdrop has coverage, so its alpha interpolates each source channel from the raw color to the mixed color
let source_channel = |over_channel: f32, mixed_channel: f32| over_channel * (1. - under_alpha) + mixed_channel * under_alpha;
let channel =
|mixed_channel: f32, over_channel: f32, under_channel: f32| (source_channel(over_channel, mixed_channel) * over_alpha + under_channel * under_alpha * (1. - over_alpha)) / result_alpha;
Color::from_rgbaf32_unchecked(
channel(mixed.r(), over.r(), under.r()),
channel(mixed.g(), over.g(), under.g()),
channel(mixed.b(), over.b(), under.b()),
result_alpha,
)
}
/// Flattens a color paint into the single color the fast path emits, stacking the rows in paint order.
/// `element_color` reads a row's color, `None` skipping rows of another element type.
pub(crate) fn composite_paint_colors<T>(list: &List<T>, element_color: impl Fn(&T) -> Option<Color>, for_mask: bool) -> Option<Color> {
let mut composited = None;
for index in 0..list.len() {
let Some(color) = list.element(index).and_then(&element_color) else { continue };
let faded = color.with_alpha(color.a() * paint_row_opacity(list, index, for_mask));
composited = Some(match composited {
// The lowest paint has nothing beneath it, so its blend mode has nothing to act on
None => faded,
Some(under) => composite_paint_over(faded, under, list.attribute_cloned_or::<BlendMode>(core_types::ATTR_BLEND_MODE, index, BlendMode::default())),
});
}
composited
}
#[derive(Default, Clone, Copy, Hash, graphene_hash::CacheHash)]
pub enum RenderOutputType {
#[default]
@@ -232,7 +323,7 @@ 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,
pub stroke_below: bool,
/// Are we rendering for a pattern content
pub inside_pattern: bool,
pub artboard_background: Option<Color>,
@@ -504,7 +595,7 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
}
}
fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
let gradient_form: GradientForm = gradient_list.attr::<GradientFormAttr>(0);
@@ -512,6 +603,7 @@ fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list
let settings = GradientSettings::from_lane_attributes(gradient_list, 0);
let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
let samples = paint_faded_samples(samples, paint_lane_opacity(gradient_list, 0, for_mask));
let peniko_stops = peniko_color_stops(&samples);
@@ -560,12 +652,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 resolved appearance snapshot, exposed so message handlers can read the paint.
#[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
/// 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<'static>>>>,
pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
pub backgrounds: Vec<Background>,
}
@@ -589,8 +678,7 @@ impl RenderMetadata {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
appearance_attributes,
backgrounds,
} = self;
upstream_footprints.extend(other.upstream_footprints.iter());
@@ -601,8 +689,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 {
@@ -651,7 +738,7 @@ impl Render for Graphic<'_> {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_svg(render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), None, render, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_svg(&Single(raster), render, render_params),
Graphic::RasterGPU(_) => (),
Graphic::Color(color) => render_color_svg(&Single(color), render, render_params),
@@ -665,7 +752,7 @@ impl Render for Graphic<'_> {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), None, scene, transform, context, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_vello(&Single(raster), scene, transform, render_params),
Graphic::RasterGPU(raster) => render_raster_gpu_vello(&Single(raster), scene, transform, context, render_params),
Graphic::Color(color) => render_color_vello(&Single(color), scene, render_params),
@@ -707,8 +794,8 @@ impl Render for Graphic<'_> {
fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_svg(&PaintOverlay::new(&Single(vector), reach.paint), render, render_params),
Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach.nested(), render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), reach.appearance, render, render_params),
Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach, render, render_params),
Graphic::Group(group) => render_group_svg(group, reach, render, render_params),
_ => element.render_svg(render, render_params),
}
@@ -716,8 +803,8 @@ fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &
fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_vello(&PaintOverlay::new(&Single(vector), reach.paint), scene, transform, context, render_params),
Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach.nested(), scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), reach.appearance, scene, transform, context, render_params),
Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach, scene, transform, context, render_params),
Graphic::Group(group) => render_group_vello(group, reach, scene, transform, context, render_params),
_ => element.render_to_vello(scene, transform, context, render_params),
}
@@ -725,13 +812,12 @@ fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene:
fn element_can_reduce_to_clip_path<'a>(element: &'a Graphic, reach: PaintReach<'a>) -> bool {
match element {
Graphic::Vector(vector) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&Single(vector), reach.paint)),
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&Single(vector), reach.appearance),
Graphic::Group(group) => match RunView::<Vector>::new(&group.content) {
Some(run) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&run, reach.paint)),
Some(run) => vector_can_reduce_to_clip_path(&run),
Some(run) => vector_can_reduce_to_clip_path(&run, reach.appearance),
None => false,
},
_ => element.can_reduce_to_clip_path(),
_ => element.can_reduce_to_clip_path(reach.appearance),
}
}
@@ -762,9 +848,8 @@ fn collect_element_metadata<'a>(
match element {
Graphic::None => {}
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach.nested(), metadata, footprint, element_id),
Graphic::Vector(vector) if reach.applies() => collect_vector_metadata(&PaintOverlay::new(&Single(vector), reach.paint), metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), metadata, footprint, element_id),
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach, metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), reach.appearance, metadata, footprint, element_id),
Graphic::RasterCPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::RasterGPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::Color(_) => {}
@@ -806,9 +891,8 @@ fn collect_group_row_metadata(group: &Group, metadata: &mut RenderMetadata, elem
fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, click_targets: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach.nested(), click_targets),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach, click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), reach.appearance, click_targets),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(click_targets),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => click_targets.extend(gradient_control_targets(&Single(gradient), |transform| transform, true)),
@@ -820,9 +904,8 @@ fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReac
fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, outlines: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach.nested(), outlines),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach, outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), reach.appearance, outlines),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(outlines),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => outlines.extend(gradient_control_targets(&Single(gradient), |transform| transform, false)),
@@ -836,12 +919,9 @@ fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintRe
fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
render_graphic_svg_with(&run, reach.into_group_graphics(), render, render_params)
render_graphic_svg_with(&run, reach, render, render_params)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => render_vector_svg(&PaintOverlay::new(&run, reach.paint), render, render_params),
false => render_vector_svg(&run, render, render_params),
}
render_vector_svg(&run, reach.appearance, render, render_params)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
render_raster_cpu_svg(&run, render, render_params)
} else if item.typed_lanes::<Raster<GPU>>().is_some() {
@@ -857,12 +937,9 @@ fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut Sv
fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
render_graphic_vello_with(&run, reach.into_group_graphics(), scene, transform, context, render_params)
render_graphic_vello_with(&run, reach, scene, transform, context, render_params)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => render_vector_vello(&PaintOverlay::new(&run, reach.paint), scene, transform, context, render_params),
false => render_vector_vello(&run, scene, transform, context, render_params),
}
render_vector_vello(&run, reach.appearance, scene, transform, context, render_params)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
render_raster_cpu_vello(&run, scene, transform, render_params)
} else if let Some(run) = RunView::<Raster<GPU>>::new(item) {
@@ -882,12 +959,9 @@ fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut S
fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
collect_graphic_metadata_with(&run, reach.into_group_graphics(), metadata, footprint, element_id)
collect_graphic_metadata_with(&run, reach, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => collect_vector_metadata(&PaintOverlay::new(&run, reach.paint), metadata, footprint, element_id),
false => collect_vector_metadata(&run, metadata, footprint, element_id),
}
collect_vector_metadata(&run, reach.appearance, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
collect_raster_metadata(&run, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Raster<GPU>>::new(item) {
@@ -903,12 +977,9 @@ fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata:
fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>, click_targets: &mut Vec<ClickTarget>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
add_graphic_upstream_click_targets_with(&run, reach.into_group_graphics(), click_targets)
add_graphic_upstream_click_targets_with(&run, reach, click_targets)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => add_vector_upstream_click_targets(&PaintOverlay::new(&run, reach.paint), click_targets),
false => add_vector_upstream_click_targets(&run, click_targets),
}
add_vector_upstream_click_targets(&run, reach.appearance, click_targets)
} else if item.typed_lanes::<Raster<CPU>>().is_some() || item.typed_lanes::<Raster<GPU>>().is_some() {
add_raster_upstream_click_targets(click_targets)
} else if let Some(run) = RunView::<Gradient>::new(item) {
@@ -921,12 +992,9 @@ fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>,
fn add_group_upstream_outline_targets<'a>(group: &'a Group, reach: PaintReach<'a>, outlines: &mut Vec<ClickTarget>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
add_graphic_upstream_outline_targets_with(&run, reach.into_group_graphics(), outlines)
add_graphic_upstream_outline_targets_with(&run, reach, outlines)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => add_vector_upstream_outline_targets(&PaintOverlay::new(&run, reach.paint), outlines),
false => add_vector_upstream_outline_targets(&run, outlines),
}
add_vector_upstream_outline_targets(&run, reach.appearance, outlines)
} else if item.typed_lanes::<Raster<CPU>>().is_some() || item.typed_lanes::<Raster<GPU>>().is_some() {
add_raster_upstream_click_targets(outlines)
} else if let Some(run) = RunView::<Gradient>::new(item) {
@@ -1397,14 +1465,19 @@ impl Render for List<Graphic<'_>> {
}
/// Emits one lane of a vector source as SVG, with no wrapping group of its own.
fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) {
fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: usize, vector: &Vector, inherited_appearance: Option<&Appearance>, render: &mut SvgRender, render_params: &RenderParams) {
let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let element_stroke = resolved.stroke.as_ref();
// 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 has_real_stroke = element_stroke.filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.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);
@@ -1412,7 +1485,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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, element_stroke).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]);
@@ -1424,26 +1497,27 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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 element_stroke.map(|x| x.align) == Some(StrokeAlign::Inside) {
MaskType::Clip
} else {
MaskType::Mask
};
let fill_graphic_list = paint_graphics::<Fill, _>(source, index);
let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows);
let fill_graphic = fill_graphic_list.and_then(|l| l.element(0));
let stroke_graphic_list = paint_graphics::<Stroke, _>(source, index);
let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows);
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())
&& element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
@@ -1463,13 +1537,12 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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));
mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance());
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
@@ -1504,7 +1577,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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();
let stroke = element_stroke.expect("push_id is only set when an aligned stroke can draw");
// `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;
@@ -1529,11 +1602,9 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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
.as_ref()
let stroke_shape_attribute = element_stroke
.map(|stroke| {
if stroke_graphic_list.is_some_and(is_paint_present) {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
@@ -1544,7 +1615,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
.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 = element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.map(|list| {
@@ -1606,7 +1677,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
}
}
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, render: &mut SvgRender, render_params: &RenderParams) {
let mut clip_mask_state: Option<(u64, MaskType)> = None;
for index in 0..source.lane_count() {
@@ -1617,11 +1688,15 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
let mut masked_by = None;
if next_clips && clip_mask_state.is_none() {
let mask_type = if vector_lane_can_reduce_to_clip_path(source, index) { MaskType::Clip } else { MaskType::Mask };
let mask_type = if vector_lane_can_reduce_to_clip_path(source, index, inherited_appearance) {
MaskType::Clip
} else {
MaskType::Mask
};
let uuid = generate_uuid();
let mut masker_svg = SvgRender::new();
render_vector_item_svg(source, index, vector, &mut masker_svg, &render_params.for_clipper());
render_vector_item_svg(source, index, vector, inherited_appearance, &mut masker_svg, &render_params.for_clipper());
render.svg_defs.push_str(&masker_svg.svg_defs);
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string());
@@ -1639,9 +1714,9 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
Some((attribute, selector)) => render.parent_tag(
"g",
|attributes| attributes.push(attribute, selector),
|render| render_vector_item_svg(source, index, vector, render, render_params),
|render| render_vector_item_svg(source, index, vector, inherited_appearance, render, render_params),
),
None => render_vector_item_svg(source, index, vector, render, render_params),
None => render_vector_item_svg(source, index, vector, inherited_appearance, render, render_params),
}
}
}
@@ -1649,24 +1724,31 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
/// Emits one lane of a vector source to Vello. `clip_masker` names the lane whose
/// paint masks this one; its layers are pushed inside this item's blend layer so the
/// mask cuts this item's own paint rather than the composited result.
#[expect(clippy::too_many_arguments, reason = "one lane's full render context: source and index, the cascade, the vello sink, and the masking lane")]
fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
source: &S,
index: usize,
inherited_appearance: Option<&Appearance>,
scene: &mut Scene,
parent_transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
clip_masker: Option<usize>,
) {
use graphic_types::vector_types::vector;
let Some(element) = source.element(index) else { return };
let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
let multiplied_transform = parent_transform * item_transform;
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows);
let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows);
let has_real_stroke = resolved.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 mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let mut element_transform = set_stroke_transform
@@ -1690,9 +1772,6 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
}
let fill_graphic_list = paint_graphics::<Fill, _>(source, index);
let stroke_graphic_list = paint_graphics::<Stroke, _>(source, index);
// If we're using opacity or a blend mode, we need to push a layer
let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal,
@@ -1703,7 +1782,7 @@ fn render_vector_item_vello<S: LaneSource<Element = 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 = resolved.stroke.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());
@@ -1736,7 +1815,7 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
&& let Some((layer_affine, layer_rect)) = layer_geometry
{
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect);
render_vector_item_vello(source, masker_index, scene, parent_transform, context, &render_params.for_clipper(), None);
render_vector_item_vello(source, masker_index, inherited_appearance, scene, parent_transform, context, &render_params.for_clipper(), None);
scene.push_layer(
peniko::Fill::NonZero,
peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn),
@@ -1748,7 +1827,8 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list else { return };
@@ -1758,11 +1838,13 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
match paint {
Graphic::None => continue,
Graphic::Color(color) => {
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
// The row's own opacity fades the pass, matching the composited SVG fast path
let color = color.with_alpha(color.a() * paint_row_opacity(fill_graphic, paint_index, render_params.for_mask));
let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
}
Graphic::Gradient(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform, render_params.for_mask) else {
continue;
};
@@ -1839,12 +1921,15 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
match stroke_graphic {
Graphic::None => continue,
Graphic::Color(color) => {
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
// The row's own opacity fades the pass, matching the composited SVG fast path
let color = color.with_alpha(color.a() * paint_row_opacity(stroke_graphic_list, paint_index, render_params.for_mask));
let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path);
}
Graphic::Gradient(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform, render_params.for_mask)
else {
continue;
};
let inverse_element_transform = if transform_is_invertible(element_transform) {
@@ -1876,13 +1961,12 @@ fn render_vector_item_vello<S: LaneSource<Element = 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));
mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance());
let vector_list = List::new_from_item(mask_item);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
@@ -1930,7 +2014,7 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
Stroke,
}
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
let order = match stroke.is_some() && wants_stroke_below {
true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
};
@@ -1956,7 +2040,14 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
}
fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
fn render_vector_vello<S: LaneSource<Element = Vector>>(
source: &S,
inherited_appearance: Option<&Appearance>,
scene: &mut Scene,
parent_transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
) {
let mut clip_masker: Option<usize> = None;
for index in 0..source.lane_count() {
@@ -1964,7 +2055,16 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
let next_clips = index + 1 < source.lane_count() && source.attr::<ClippingMask>(index + 1);
let becomes_masker = next_clips && clip_masker.is_none();
render_vector_item_vello(source, index, scene, parent_transform, context, render_params, if becomes_masker { None } else { clip_masker });
render_vector_item_vello(
source,
index,
inherited_appearance,
scene,
parent_transform,
context,
render_params,
if becomes_masker { None } else { clip_masker },
);
if becomes_masker {
clip_masker = Some(index);
@@ -1974,7 +2074,13 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
}
}
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(
source: &S,
inherited_appearance: Option<&Appearance>,
metadata: &mut RenderMetadata,
footprint: Footprint,
caller_element_id: Option<NodeId>,
) {
// 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();
@@ -1988,6 +2094,10 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
let layer_path: &[NodeId] = source.attr::<EditorLayerPath>(index);
let layer = layer_path.last().copied();
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
if let Some(element_id) = caller_element_id.or(layer) {
let reference_transform = *reference_transforms.entry(element_id).or_insert(transform);
let reference_inverse = if transform_is_invertible(reference_transform) {
@@ -2002,12 +2112,12 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
let item_relative_transform = reference_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform);
extend_targets_from_vector(&mut click_targets_unwrapped, &resolved, click_target_vector, item_relative_transform);
accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new));
// Outlines always use source geometry so the visual outline reflects actual letterforms
let mut outlines_unwrapped = Vec::new();
extend_targets_from_vector(&mut outlines_unwrapped, source, index, element, item_relative_transform);
extend_targets_from_vector(&mut outlines_unwrapped, &resolved, element, 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.
@@ -2017,11 +2127,8 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
e.insert(Arc::new(element.clone()));
if let Some(fill_graphic) = source.attr::<Fill>(index).filter(|list| is_paint_present(list)) {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone()));
}
if let Some(stroke_graphic) = source.attr::<Stroke>(index).filter(|list| is_paint_present(list)) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.clone()));
if let Some(appearance) = appearance {
metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone()));
}
}
@@ -2059,7 +2166,7 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
}
}
fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, click_targets: &mut Vec<ClickTarget>) {
fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, click_targets: &mut Vec<ClickTarget>) {
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::<Transform>(index);
@@ -2067,39 +2174,45 @@ fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
let vector = source.attr::<EditorClickTarget>(index).unwrap_or(element);
extend_targets_from_vector(click_targets, source, index, vector, transform);
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
extend_targets_from_vector(click_targets, &resolved, vector, transform);
}
}
fn add_vector_upstream_outline_targets<S: LaneSource<Element = Vector>>(source: &S, outlines: &mut Vec<ClickTarget>) {
fn add_vector_upstream_outline_targets<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, outlines: &mut Vec<ClickTarget>) {
// Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::<Transform>(index);
extend_targets_from_vector(outlines, source, index, element, transform);
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
extend_targets_from_vector(outlines, &resolved, element, transform);
}
}
impl Render for List<Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_vector_svg(self, render, render_params)
render_vector_svg(self, None, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_vector_vello(self, scene, parent_transform, context, render_params)
render_vector_vello(self, None, scene, parent_transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
collect_vector_metadata(self, metadata, footprint, caller_element_id)
collect_vector_metadata(self, None, metadata, footprint, caller_element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
add_vector_upstream_click_targets(self, click_targets)
add_vector_upstream_click_targets(self, None, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
add_vector_upstream_outline_targets(self, outlines)
add_vector_upstream_outline_targets(self, None, outlines)
}
fn new_ids_from_hash(&mut self, reference: Option<NodeId>) {
@@ -2111,15 +2224,15 @@ 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<S: LaneSource<Element = Vector>>(targets: &mut Vec<ClickTarget>, source: &S, index: usize, geometry: &Vector, transform: DAffine2) {
let filled = has_paint::<Fill, _>(source, index);
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, resolved: &graphic_types::appearance::FillAndStroke<'_>, geometry: &Vector, transform: DAffine2) {
let filled = resolved.fill_paint.and_then(paint_cell_rows).is_some();
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 = resolved.stroke.as_ref().map_or(0., |stroke| {
if stroke.align.is_not_centered() && all_subpaths_closed {
stroke.weight * 2.
} else {
@@ -2560,6 +2673,7 @@ fn render_gradient_svg<S: LaneSource<Element = Gradient>>(source: &S, render: &m
}
let (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let samples = paint_faded_samples(samples, paint_lane_opacity(source, index, render_params.for_mask));
let mut stop_string = String::new();
for (position, color, original_midpoint) in samples {
@@ -3169,23 +3283,23 @@ impl Render for RunView<'_, Graphic<'_>> {
impl Render for RunView<'_, Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_vector_svg(self, render, render_params)
render_vector_svg(self, None, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_vector_vello(self, scene, parent_transform, context, render_params)
render_vector_vello(self, None, scene, parent_transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
collect_vector_metadata(self, metadata, footprint, caller_element_id)
collect_vector_metadata(self, None, metadata, footprint, caller_element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
add_vector_upstream_click_targets(self, click_targets)
add_vector_upstream_click_targets(self, None, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
add_vector_upstream_outline_targets(self, outlines)
add_vector_upstream_outline_targets(self, None, outlines)
}
}
@@ -3336,7 +3450,6 @@ impl SvgRenderAttrs<'_> {
mod group_walk_tests {
use super::*;
use core_types::record::{FieldWrite, RunBuilder, element_write_hashed};
use graphic_types::markers::Fill;
use graphic_types::vector_types::vector::PointId;
fn unit_square_at(corner: DVec2) -> Vector {
@@ -3347,6 +3460,11 @@ mod group_walk_tests {
List::new_from_element(Graphic::Color(Color::from_rgbaf32(0.8, 0.2, 0.33, 1.).unwrap()))
}
/// The appearance the fill node stamps, so test content mirrors node output.
fn fill_appearance(paint: &List<Graphic<'static>>) -> Appearance {
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint.clone()))
}
fn rendered_svg(render: impl FnOnce(&mut SvgRender)) -> (String, String) {
let mut svg_render = SvgRender::new();
render(&mut svg_render);
@@ -3359,9 +3477,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(3., 1.))];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 2).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 2).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
builder.push(vectors[1].clone()).unwrap();
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3378,9 +3497,10 @@ mod group_walk_tests {
let paint = color_paint();
let inner = Graphic::Vector(unit_square_at(DVec2::ZERO));
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Graphic>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Graphic>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(inner.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3397,9 +3517,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO)];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3414,7 +3535,14 @@ mod group_walk_tests {
assert!(native.local_transforms.contains_key(&caller));
assert!(native.upstream_footprints.contains_key(&caller));
assert_eq!(native.vector_data.get(&caller).map(|vector| vector.as_ref()), Some(&vectors[0]));
assert!(native.fill_attributes.get(&caller).is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_)))));
assert!(
native
.appearance_attributes
.get(&caller)
.and_then(|appearance| appearance.first_paint_of(graphic_types::appearance::Cover::Fill))
.and_then(paint_cell_rows)
.is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_))))
);
}
#[test]
@@ -3422,9 +3550,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(2., 2.))];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 2).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 2).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
builder.push(vectors[1].clone()).unwrap();
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3441,6 +3570,59 @@ mod group_walk_tests {
Graphic::Group(group).add_upstream_outline_targets(&mut native_outlines);
assert_eq!(native_outlines, legacy);
}
#[test]
fn stacked_paint_colors_composite_in_straight_alpha() {
// A half-transparent red over an opaque blue lands halfway between the two
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 1., 1.)));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 0.5)));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.g() - 0.).abs() < 1e-5, "green was {}", composited.g());
assert!((composited.b() - 0.5).abs() < 1e-5, "blue was {}", composited.b());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
}
#[test]
fn stacked_paint_blending_interpolates_by_backdrop_coverage() {
// Multiply over half-covering black only half-multiplies the red
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 0., 0.5)));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
// Multiply over no backdrop at all leaves the source color untouched
let mut list = List::new();
list.push(Item::new_from_element(Color::TRANSPARENT));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 1.).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
}
#[test]
fn a_paint_rows_own_opacity_fades_its_color() {
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)));
list.set_attribute(core_types::ATTR_OPACITY, 0, 0.5_f64);
list.set_attribute(core_types::ATTR_OPACITY_FILL, 0, 0.5_f64);
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.a() - 0.25).abs() < 1e-5, "both opacities fade the paint, alpha was {}", composited.a());
// A masker drops the fill opacity so it cannot reach the content clipped to it
let masked = composite_paint_colors(&list, |color| Some(*color), true).expect("a non-empty paint list composites to a color");
assert!((masked.a() - 0.5).abs() < 1e-5, "the mask keeps only the plain opacity, alpha was {}", masked.a());
}
}
#[cfg(test)]

View File

@@ -22,6 +22,20 @@ core_types::attribute! {
/// by clicking anywhere within their bounds, not just the filled letterform. An absent
/// value means the item's own geometry is the click target.
pub EditorClickTarget("editor:click_target"): Option<&crate::Vector>;
/// Stroke coverage's line thickness. Absent when equal to the stroke default.
pub Weight("weight"): f64;
/// Stroke coverage's dash lengths, alternating dash and gap. Absent when the pattern is solid.
pub DashPattern("dash_pattern"): Option<&Vec<f64>>;
/// Stroke coverage's phase offset into the dash pattern. Absent when equal to the stroke default.
pub DashOffset("dash_offset"): f64;
/// Stroke coverage's shape at open endpoints. Absent when equal to the stroke default.
pub Cap("cap"): crate::vector::style::StrokeCap;
/// Stroke coverage's corner curvature. Absent when equal to the stroke default.
pub Join("join"): crate::vector::style::StrokeJoin;
/// Stroke coverage's miter-to-bevel conversion threshold. Absent when equal to the stroke default.
pub JoinMiterLimit("join_miter_limit"): f64 = 4.;
/// Stroke coverage's alignment to the path centerline. Absent when equal to the stroke default.
pub Align("align"): crate::vector::style::StrokeAlign;
}
// The value types a name-generic attribute can name here, so a compile-time
@@ -41,6 +55,13 @@ pub const ATTR_GRADIENT_SPACE: &str = GradientSpace::NAME;
pub const ATTR_GRADIENT_HUE_DIRECTION: &str = GradientHueDirection::NAME;
pub const ATTR_GRADIENT_INTERPOLATION: &str = GradientInterpolation::NAME;
pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME;
pub const ATTR_WEIGHT: &str = Weight::NAME;
pub const ATTR_DASH_PATTERN: &str = DashPattern::NAME;
pub const ATTR_DASH_OFFSET: &str = DashOffset::NAME;
pub const ATTR_CAP: &str = Cap::NAME;
pub const ATTR_JOIN: &str = Join::NAME;
pub const ATTR_JOIN_MITER_LIMIT: &str = JoinMiterLimit::NAME;
pub const ATTR_ALIGN: &str = Align::NAME;
#[cfg(test)]
mod tests {
@@ -53,6 +74,24 @@ mod tests {
assert_eq!(info("gradient_form").unwrap().value_type, TypeId::of::<crate::gradient::GradientForm>());
assert_eq!(info("gradient_spread").unwrap().value_type, TypeId::of::<crate::gradient::GradientSpread>());
assert_eq!(info("editor:click_target").unwrap().value_type, TypeId::of::<Option<&'static crate::Vector>>());
assert_eq!(info("weight").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("dash_pattern").unwrap().value_type, TypeId::of::<Option<&'static Vec<f64>>>());
assert_eq!(info("dash_offset").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("cap").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeCap>());
assert_eq!(info("join").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeJoin>());
assert_eq!(info("join_miter_limit").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("align").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeAlign>());
}
#[test]
fn stroke_parameter_defaults_match_the_stroke_struct() {
let defaults = crate::vector::style::Stroke::default();
assert_eq!(<Weight as Attribute>::default(), defaults.weight);
assert_eq!(<DashOffset as Attribute>::default(), defaults.dash_offset);
assert_eq!(<JoinMiterLimit as Attribute>::default(), defaults.join_miter_limit);
assert_eq!(<Cap as Attribute>::default(), defaults.cap);
assert_eq!(<Join as Attribute>::default(), defaults.join);
assert_eq!(<Align as Attribute>::default(), defaults.align);
}
#[test]

View File

@@ -235,8 +235,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 +248,6 @@ impl Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -287,7 +284,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 +398,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 glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
use std::collections::HashMap;
@@ -17,8 +16,6 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, dyn_any::DynAny)]
#[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]>,
@@ -31,7 +28,6 @@ pub struct 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(),
@@ -45,7 +41,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);
}
}
@@ -251,10 +246,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());
@@ -542,40 +537,14 @@ 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;
}
}
}
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,
}
}

View File

@@ -11,9 +11,9 @@ use core_types::registry::types::Angle;
use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DVec2};
use graphic_types::Vector;
use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, is_lone_anonymous_leaf, walk_vector_rows};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
use graphic_types::markers::EditorMergedLayers;
use raster_types::{CPU, GPU, Raster};
use vector_types::gradient::{GradientForm as GradientFormValue, GradientHueDirection, GradientSpace, GradientSpread};
use vector_types::markers::GradientCyclic;
@@ -424,8 +424,6 @@ fn merged_layers_snapshot<'e>(arena: &'e Arena, mut snapshot: List<Graphic<'stat
type FlattenedVectorRow<'a, 'e> = (
Lane<'a, Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
Attr<'e, EditorLayerPath>,
@@ -436,14 +434,6 @@ type FlattenedVectorRow<'a, 'e> = (
/// walk's composition, paint and layer path overriding, and `snapshot` parked
/// as the merged layers where given.
fn emit_vector_row<'a, 'e>(arena: &'e Arena, carrier: Lane<'a, Graphic<'static>>, row: List<Vector>, snapshot: Option<List<Graphic<'static>>>) -> Result<FlattenedVectorRow<'a, 'e>, Interrupt> {
let park_paint = |paint: Option<&Option<List<Graphic<'static>>>>| {
paint
.and_then(|paint| paint.as_ref())
.map(|paint| arena.alloc_sized_keyed(paint.clone(), 0).map(|(parked, _)| parked).ok_or_else(arena_exhausted))
.transpose()
};
let fill = park_paint(row.attribute(ATTR_FILL, 0))?;
let stroke = park_paint(row.attribute(ATTR_STROKE, 0))?;
let layer_path: Vec<NodeId> = row.attribute(ATTR_EDITOR_LAYER_PATH, 0).cloned().unwrap_or_default();
let (layer_path, _) = arena.alloc(layer_path).ok_or_else(arena_exhausted)?;
@@ -454,8 +444,6 @@ fn emit_vector_row<'a, 'e>(arena: &'e Arena, carrier: Lane<'a, Graphic<'static>>
Ok((
carrier.map_element(element),
Attr(transform),
Attr(fill),
Attr(stroke),
Attr(row.attribute_cloned_or(ATTR_OPACITY, 0, 1.)),
Attr(row.attribute_cloned_or(ATTR_OPACITY_FILL, 0, 1.)),
Attr(layer_path.as_slice()),
@@ -474,8 +462,6 @@ pub fn flatten_vector<'e>(
IList<(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
Attr<'e, EditorLayerPath>,

View File

@@ -12,7 +12,7 @@ pub use graphene_application_io as application_io;
pub use graphene_core;
pub use graphene_core::debug;
pub use graphic_nodes;
pub use graphic_types::{Artboard, Graphic, Vector};
pub use graphic_types::{Appearance, Artboard, Cover, Coverage, Graphic, Vector};
pub use math_nodes;
pub use path_bool_nodes;
pub use raster_nodes;

View File

@@ -43,7 +43,7 @@ pub struct CacheKey {
pub for_mask: bool,
pub thumbnail: bool,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub stroke_below: bool,
pub animation_time_ms: i64,
pub real_time_ms: i64,
pub pointer: [u8; 16],
@@ -60,7 +60,7 @@ impl CacheKey {
for_mask: bool,
thumbnail: bool,
aligned_strokes: bool,
override_paint_order: bool,
stroke_below: bool,
animation_time: f64,
real_time: f64,
pointer: Option<DVec2>,
@@ -85,7 +85,7 @@ impl CacheKey {
for_mask,
thumbnail,
aligned_strokes,
override_paint_order,
stroke_below,
animation_time_ms: (animation_time * 1000.).round() as i64,
real_time_ms: (real_time * 1000.).round() as i64,
pointer: pointer_bytes,
@@ -360,7 +360,7 @@ pub fn render_output_cache(
render_params.for_mask,
render_params.thumbnail,
render_params.aligned_strokes,
render_params.override_paint_order,
render_params.stroke_below,
ctx.try_animation_time().unwrap_or(0.),
ctx.try_real_time().unwrap_or(0.),
ctx.try_pointer_position(),

View File

@@ -3,12 +3,13 @@ use core_types::list::{Item, List};
use core_types::node::Lane;
use core_types::{ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Ctx};
use glam::{DAffine2, DVec2};
use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, set_paint_attribute_at};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::appearance::Appearance;
use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
use graphic_types::vector_types::vector::PointId;
use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
use graphic_types::{ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList, Vector};
use graphic_types::{Graphic, IntoGraphicList, Vector};
use linesweeper::topology::Topology;
use linesweeper::{BinaryOp, FillRule, binary_op};
use smallvec::SmallVec;
@@ -25,7 +26,7 @@ fn boolean_core<'e>(
flattened: List<Vector>,
snapshot: List<Graphic<'static>>,
operation: BooleanOperation,
) -> Result<(Vector, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Vector, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
// The first index is the bottom of the stack
let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation);
@@ -36,7 +37,6 @@ fn boolean_core<'e>(
let result_vector = result_vector_list.element_mut(0).unwrap();
Vector::transform(result_vector, transform);
result_vector.set_stroke_transform(DAffine2::IDENTITY);
// Clean up the boolean operation result by merging duplicated points
let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
@@ -49,17 +49,12 @@ fn boolean_core<'e>(
trace: Vec::new(),
})
};
let park_paint = |paint: Option<List<Graphic<'static>>>| -> Result<Option<&'e List<Graphic>>, core_types::gpoll::Interrupt> {
match paint {
Some(list) => Ok(Some(arena.alloc_sized_keyed(list, 0).ok_or_else(exhausted)?.0)),
None => Ok(None),
}
};
let element = result_vector_list.element(0).cloned().unwrap_or_default();
use core_types::lane::LaneSource;
let fill = park_paint(result_vector_list.attr::<Fill>(0).filter(|paint| is_paint_present(paint)).cloned())?;
let stroke = park_paint(result_vector_list.attr::<Stroke>(0).filter(|paint| is_paint_present(paint)).cloned())?;
let appearance = match result_vector_list.attr::<AppearanceMarker>(0).cloned() {
Some(appearance) => Some(arena.alloc_sized_keyed(appearance, 0).ok_or_else(exhausted)?.0),
None => None,
};
// Snapshot the input layers so the renderer can recurse into them for
// editor click-target preservation.
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
@@ -69,8 +64,7 @@ fn boolean_core<'e>(
Ok((
element,
Attr(result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(Some(merged_layers)),
))
}
@@ -88,17 +82,17 @@ fn boolean_operation<'e>(
/// Intersection cuts away all but the overlapping areas shared by every path.
/// Difference cuts away the overlapping areas shared by every path, leaving only the non-overlapping areas.
operation: BooleanOperation,
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
if content.is_empty() {
return Err(core_types::gpoll::GraphError::past_end().into());
}
let item = content.as_group_item();
let flattened = flatten_vector_run(GraphicLevel::Run(&item), Ancestors::NONE, PaintReach::NONE);
let snapshot = graphic_types::graphic::run_to_list::<Graphic>(&item).expect("the run holds the row's element type").into_graphic_list();
let (element, transform, fill, stroke, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
let (element, transform, appearance, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
// The merge presents the bottom-of-stack lane's blending, clipping and layer
// path, carried rather than re-read: one named lane instead of four copies.
Ok((content.lane(0).map_element(element), transform, fill, stroke, merged))
Ok((content.lane(0).map_element(element), transform, appearance, merged))
}
/// The boolean operation over a plain vector level, as [`boolean_operation`].
@@ -107,15 +101,15 @@ fn boolean_operation_vector<'e>(
ctx: impl Ctx + ExtractArena<'e> + core_types::InjectIndex + Copy,
content: IList<Vector>,
operation: BooleanOperation,
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
if content.is_empty() {
return Err(core_types::gpoll::GraphError::past_end().into());
}
let item = content.as_group_item();
let flattened = graphic_types::graphic::run_to_list::<Vector>(&item).expect("the run holds vector lanes");
let snapshot = graphic_types::graphic::run_to_list::<Vector>(&item).expect("the run holds the row's element type").into_graphic_list();
let (element, transform, fill, stroke, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
Ok((content.lane(0).map_element(element), transform, fill, stroke, merged))
let (element, transform, appearance, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
Ok((content.lane(0).map_element(element), transform, appearance, merged))
}
pub use _boolean_operation_vector_mod::boolean_operation_vector_entries;
@@ -203,12 +197,7 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
bake_paint_transforms(&mut attributes, copy_from_transform);
let copy_from = vector.element(index).unwrap();
let element = Vector {
stroke: copy_from.stroke.clone(),
..Default::default()
};
Item::from_parts(element, attributes)
Item::from_parts(Vector::default(), attributes)
} else {
Item::<Vector>::default()
};
@@ -312,13 +301,7 @@ impl Ancestors {
fn push_leaf_vector_row(out: &mut List<Vector>, level: GraphicLevel<'_>, index: usize, vector: &Vector, ancestors: Ancestors, reach: PaintReach<'_>) {
let out_index = out.len();
out.push(Item::from_parts(vector.clone(), graphic_types::graphic::lane_attributes(level, index)));
if reach.applies() {
for (key, slot) in [(ATTR_FILL, reach.paint.fill), (ATTR_STROKE, reach.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, out_index, key, paint.clone());
}
}
}
stamp_inherited_appearance(out, out_index, reach.appearance);
ancestors.compose(out, out_index);
}
@@ -327,13 +310,7 @@ fn push_vector_rows(out: &mut List<Vector>, rows: &List<Vector>, composed: Ances
let Some(item) = rows.clone_item(row) else { continue };
let index = out.len();
out.push(item);
if reach.applies() {
for (key, slot) in [(ATTR_FILL, reach.paint.fill), (ATTR_STROKE, reach.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, index, key, paint.clone());
}
}
}
stamp_inherited_appearance(out, index, reach.appearance);
composed.compose(out, index);
}
}
@@ -347,6 +324,15 @@ fn push_text_rows(out: &mut List<Vector>, text: &List<String>, composed: Ancesto
}
}
/// The cascade's resolved appearance lands on a row whose own is undeclared, since a declared row wins wholesale.
fn stamp_inherited_appearance(out: &mut List<Vector>, index: usize, inherited: Option<&Appearance>) {
if let Some(appearance) = inherited
&& out.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index).and_then(Appearance::declared).is_none()
{
out.set_attribute(graphic_types::ATTR_APPEARANCE, index, appearance.clone());
}
}
fn push_union(out: &mut List<Vector>, flattened: List<Vector>) {
// The union emits one blank operand even from an empty list, which would fabricate a region out of nothing
if flattened.len() == 0 {
@@ -375,7 +361,7 @@ fn flatten_vector_run_into<'a>(out: &mut List<Vector>, level: GraphicLevel<'a>,
let composed = ancestors.through(&level, index);
match element {
Graphic::Vector(vector) => push_leaf_vector_row(out, level, index, vector, ancestors, reach),
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach.nested())),
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach)),
Graphic::Group(group) => flatten_group(out, group, composed, reach),
Graphic::Text(text) => {
let one = List::new_from_item(Item::from_parts(text.clone(), graphic_types::graphic::lane_attributes(level, index)));
@@ -395,7 +381,7 @@ fn flatten_group(out: &mut List<Vector>, group: &core_types::record::Group, comp
if let Some(rows) = graphic_types::graphic::run_to_list::<Vector>(item) {
push_vector_rows(out, &rows, composed, reach);
} else if core_types::record::RunView::<Graphic>::new(item).is_some() {
push_union(out, flatten_vector_run(GraphicLevel::Run(item), composed, reach.into_group_graphics()));
push_union(out, flatten_vector_run(GraphicLevel::Run(item), composed, reach));
} else if let Some(text) = graphic_types::graphic::run_to_list::<String>(item) {
push_text_rows(out, &text, composed);
}
@@ -502,6 +488,12 @@ mod tests {
List::new_from_element(Graphic::Color(Color::BLACK))
}
/// The single-fill appearance a built row paints with.
fn fill_appearance(paint: List<Graphic<'static>>) -> Appearance {
use graphic_types::appearance::Coverage;
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint))
}
#[test]
fn the_native_flatten_reads_lanes_groups_and_reach() {
let inner_vector = square(DVec2::ZERO);
@@ -515,7 +507,7 @@ mod tests {
top.push(Item::new_from_element(Graphic::Color(Color::BLACK)));
top.push(Item::new_from_element(Graphic::Group(Group { row: None, content: inner_item })));
top.set_attribute(ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(5., 5.)));
set_paint_attribute_at(&mut top, 0, ATTR_FILL, black_paint());
top.set_attribute(graphic_types::ATTR_APPEARANCE, 0, fill_appearance(black_paint()));
top.set_attribute(ATTR_OPACITY, 1, 0.5);
top.set_attribute(ATTR_TRANSFORM, 2, DAffine2::from_scale(DVec2::splat(3.)));
@@ -523,10 +515,16 @@ mod tests {
// The color lane bounds no region, so it serves no operand and the group's row lands at 1
assert_eq!(rows.len(), 2);
let fill_of = |index: usize| {
rows.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index)
.and_then(|appearance| appearance.first_paint_of(graphic_types::appearance::Cover::Fill))
.and_then(graphic_types::graphic::paint_cell_rows)
};
// Lane 0: the leaf row keeps its lane attributes, with the lane fill
// present and the ancestor composition the identity.
assert_eq!(rows.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0), DAffine2::from_translation(DVec2::new(5., 5.)));
assert!(graphic_types::graphic::paint_graphics::<Fill, _>(&rows, 0).is_some());
assert!(fill_of(0).is_some());
// Lane 1: the group's vector run serves its row under the lane
// transform.

View File

@@ -17,10 +17,11 @@ use core_types::transform::Transform;
use core_types::uuid::NodeId;
use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DMat2, DVec2};
use graphic_types::graphic::{MapVectorContent, bake_paint_transforms, has_paint, is_paint_present, set_paint_attribute_at};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::appearance::{Appearance, Cover, CoverPlacement, Coverage};
use graphic_types::graphic::{MapVectorContent, bake_paint_transforms, is_paint_present};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList};
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, Graphic, IntoGraphicList};
use graphic_types::{Artboard, Vector};
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
@@ -39,7 +40,7 @@ use vector_types::vector::misc::{
CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups,
bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
};
use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{DashPattern, Gradient, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::vector::{PointDomain, RegionDomain};
@@ -91,18 +92,18 @@ fn assign_colors<'e>(
/// The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once.
#[widget(ParsedWidgetOverride::Custom = "assign_colors_repeat_every")]
repeat_every: u32,
) -> Result<IList<(Lane<Vector>, Attr<'e, Fill>, Attr<'e, StrokeAttr>)>, Interrupt> {
) -> Result<IList<(Lane<Vector>, Attr<'e, AppearanceMarker>)>, Interrupt> {
let lane = ctx.index() as usize;
if lane >= content.len() {
return Err(GraphError::past_end().into());
}
let element = content.element_ref(lane).clone();
let park_existing = |paint: Option<&List<Graphic<'static>>>| -> Result<Option<&'e List<Graphic>>, Interrupt> { paint.map(|paint| park_paint(ctx.arena(), paint.clone())).transpose() };
let existing_fill = park_existing(content.lane(lane).attr::<Fill>())?;
let existing_stroke = park_existing(content.lane(lane).attr::<StrokeAttr>())?;
let existing_appearance = content.lane(lane).attr::<AppearanceMarker>().cloned();
let park_appearance_attr = |appearance: Option<Appearance>| -> Result<Option<&'e Appearance>, Interrupt> { appearance.map(|appearance| park_appearance(ctx.arena(), appearance)).transpose() };
if gradient.is_empty() {
return Ok((content.lane(lane).map_element(element), Attr(existing_fill), Attr(existing_stroke)));
let parked_appearance = park_appearance_attr(existing_appearance)?;
return Ok((content.lane(lane).map_element(element), Attr(parked_appearance)));
}
let settings = vector_types::GradientSettings {
spread: gradient.lane(0).attr::<vector_types::markers::GradientSpread>(),
@@ -123,17 +124,20 @@ fn assign_colors<'e>(
let color = assign_color_at(gradient_element, settings, lane, content.len(), randomize, seed, repeat_every);
let paint = List::new_from_element(color).into_graphic_list();
let parked = park_paint(ctx.arena(), paint)?;
let fill_attr = match fill {
true => Some(parked),
false => existing_fill,
};
let stroke_attr = match stroke && element.stroke.is_some() {
true => Some(parked),
false => existing_stroke,
};
Ok((content.lane(lane).map_element(element), Attr(fill_attr), Attr(stroke_attr)))
// The recolor lands on the appearance's coverage paints
let mut appearance = existing_appearance.unwrap_or_default();
let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
}
// The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
}
let parked_appearance = park_appearance_attr(Some(appearance))?;
Ok((content.lane(lane).map_element(element), Attr(parked_appearance)))
}
#[allow(clippy::too_many_arguments)]
@@ -230,15 +234,20 @@ fn assign_colors_graphic<'e>(
let element = match rows {
Some(mut rows) => {
for row in 0..rows.len() {
let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some());
let color = assign_color_at(gradient_element, settings, position + row, length, randomize, seed, repeat_every);
let paint = List::new_from_element(color).into_graphic_list();
if fill {
set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone());
// The recolor lands on the row's appearance coverage paints
let mut appearance = rows.attribute_cloned_or_default::<Appearance>(graphic_types::ATTR_APPEARANCE, row);
let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
}
if stroke && has_stroke {
set_paint_attribute_at(&mut rows, row, ATTR_STROKE, paint.clone());
// The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
}
rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance);
}
let content = core_types::record::GroupItem::from_list(rows, ctx.arena()).ok_or_else(|| Interrupt::from(GraphError::new("the arena is exhausted")))?;
Graphic::Group(core_types::record::Group { row: None, content })
@@ -278,16 +287,27 @@ fn assign_colors_graphic_extent(
pub use _assign_colors_graphic_mod::assign_colors_graphic_entries;
/// Keyed, so a group-free paint's promote moves this header rather than
/// Keyed, so a group-free appearance's promote moves this header rather than
/// cloning the content it owns.
fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List<Graphic<'static>>) -> Result<&'e List<Graphic<'static>>, Interrupt> {
let (parked, _) = arena.alloc_sized_keyed(paint, 0).ok_or(GraphError {
/// Keyed so a group-free appearance's promote moves this header.
fn park_appearance(arena: &core_types::arena::Arena, appearance: Appearance) -> Result<&Appearance, Interrupt> {
let (parked, _) = arena.alloc_sized_keyed(appearance, 0).ok_or(GraphError {
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
trace: Vec::new(),
})?;
Ok(parked)
}
/// Appends one coverage to the content's appearance following the painter's algorithm:
/// the most downstream paint node in the chain paints on top unless it asks for the
/// below placement. The coverage's paint is the canonical paint list carried as one
/// graphic cell, the input lane's own envelope dropped.
fn stamped_appearance(content_appearance: Option<&Appearance>, coverage: Coverage, paint: &List<Graphic<'static>>, placement: CoverPlacement) -> Appearance {
let mut appearance = content_appearance.cloned().unwrap_or_default();
appearance.replace_or_insert(coverage, Graphic::Graphic(paint.clone()), placement);
appearance
}
/// The gradient defaulting the legacy fill performed, applied to the nested
/// stops list the paint table wraps.
fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>, gradient_type: GradientForm, transform: Option<DAffine2>) {
@@ -329,7 +349,7 @@ fn paint_table(paint: core_types::node::List<'_, Graphic<'_>>) -> List<Graphic<'
fn fill<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
/// The content with vector paths to apply the fill style to.
(element, _content_fill): (Vector, Attr<Fill>),
(element, content_appearance): (Vector, Attr<AppearanceMarker>),
/// The fill to paint the path with.
#[default(Color::BLACK)]
fill: IList<Graphic<'static>>,
@@ -338,35 +358,36 @@ fn fill<'e>(
_gradient_form: GradientForm,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Vector, Attr<'e, Fill>), Interrupt> {
) -> Result<(Vector, Attr<'e, AppearanceMarker>), Interrupt> {
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_form, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(Some(parked_appearance))))
}
/// The fill over graphic lanes: the marker parks on the lane and the render
/// boundary moves it onto the interior vector lists the legacy paint readers
/// inspect. Registered under the fill's identifier.
/// The fill over graphic lanes: the appearance parks on the lane and cascades
/// to the vectors beneath it. Registered under the fill's identifier.
#[node_macro::node(category(""))]
fn fill_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, _content_fill): (Graphic<'static>, Attr<Fill>),
(element, content_appearance): (Graphic<'static>, Attr<AppearanceMarker>),
#[default(Color::BLACK)] fill: IList<Graphic<'static>>,
_backup_color: IList<Color>,
_backup_gradient: IList<Gradient>,
_gradient_form: GradientForm,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Graphic<'static>, Attr<'e, Fill>), Interrupt> {
) -> Result<(Graphic<'static>, Attr<'e, AppearanceMarker>), Interrupt> {
let bounds = match BoundingBox::bounding_box(&element, DAffine2::IDENTITY, false) {
RenderBoundingBox::Rectangle(bounds) => Some(bounds),
_ => None,
};
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, bounds, _gradient_form, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(Some(parked_appearance))))
}
/// Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.
@@ -374,7 +395,7 @@ fn fill_graphic_leveled<'e>(
fn stroke<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
/// The content with vector paths to apply the stroke style to.
(element, content_transform): (Vector, Attr<TransformAttr>),
(element, content_transform, content_appearance): (Vector, Attr<TransformAttr>, Attr<AppearanceMarker>),
/// The stroke paint.
#[default(Color::BLACK)]
paint: IList<Graphic<'static>>,
@@ -391,72 +412,12 @@ fn stroke<'e>(
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
#[default(4.)]
miter_limit: f64,
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
paint_order: PaintOrder,
/// The stroke dash pattern. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.
dash_pattern: DashPattern,
/// The phase offset distance from the starting point of the dash pattern.
#[unit(" px")]
dash_offset: f64,
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let mut stroke = Stroke {
weight,
dash_lengths,
dash_offset,
cap,
join,
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
stroke.transform *= *content_transform;
let mut element = element;
element.stroke = Some(stroke);
let paint = paint_table(paint);
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(*content_transform), Attr(Some(parked))))
}
/// The vector items of a graphic lane's interior, one wrap level deep, the
/// reach of the pre-flip broadcast over a legacy list.
fn for_each_interior_vector_mut(element: &mut Graphic, mut f: impl FnMut(&mut Vector, DAffine2)) {
match element {
Graphic::Vector(vector) => f(vector, DAffine2::IDENTITY),
Graphic::Graphic(children) => {
for index in 0..children.len() {
let transform: DAffine2 = children.attribute_cloned_or_default(ATTR_TRANSFORM, index);
if let Some(Graphic::Vector(vector)) = children.element_mut(index) {
f(vector, transform);
}
}
}
_ => {}
}
}
/// The stroke over graphic lanes: the style applies to the interior vectors,
/// the paint marker parks on the lane for the render boundary to place.
/// Registered under the stroke's identifier.
#[node_macro::node(category(""))]
fn stroke_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, content_transform): (Graphic<'static>, Attr<TransformAttr>),
#[default(Color::BLACK)] paint: IList<Graphic<'static>>,
#[unit(" px")]
#[default(2.)]
weight: f64,
align: StrokeAlign,
cap: StrokeCap,
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
paint_order: PaintOrder,
dash_pattern: DashPattern,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let stroke = Stroke {
weight,
@@ -467,19 +428,57 @@ fn stroke_graphic_leveled<'e>(
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
let mut element = element;
for_each_interior_vector_mut(&mut element, |vector, transform| {
let mut stroke = stroke.clone();
stroke.transform *= transform;
vector.stroke = Some(stroke);
});
// The coverage records the stroke's authoring space: the item transform, translation included
let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform;
let paint = paint_table(paint);
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(*content_transform), Attr(Some(parked))))
// A below stroke is the chain running the stroke node before the fill, so the coverage appends above
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(*content_transform), Attr(Some(parked_appearance))))
}
/// The stroke over graphic lanes: the appearance parks on the lane and cascades
/// to the vectors beneath it. Registered under the stroke's identifier.
#[node_macro::node(category(""))]
fn stroke_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, content_transform, content_appearance): (Graphic<'static>, Attr<TransformAttr>, Attr<AppearanceMarker>),
#[default(Color::BLACK)] paint: IList<Graphic<'static>>,
#[unit(" px")]
#[default(2.)]
weight: f64,
align: StrokeAlign,
cap: StrokeCap,
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
dash_pattern: DashPattern,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let stroke = Stroke {
weight,
dash_lengths,
dash_offset,
cap,
join,
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
};
// The coverage records the stroke's authoring space: the lane transform, translation included
let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform;
let paint = paint_table(paint);
// A below stroke is the chain running the stroke node before the fill, so the coverage appends above
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(*content_transform), Attr(Some(parked_appearance))))
}
pub use _fill_graphic_leveled_mod::fill_graphic_leveled_entries;
@@ -630,10 +629,7 @@ fn round_corners<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
// Convert 0-100 to 0-0.5
let edge_length_limit = edge_length_limit * 0.005;
let mut result = Vector {
stroke: source.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
// Grab the initial point ID as a stable starting point
let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate());
@@ -1014,8 +1010,6 @@ fn box_warp<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
});
}
result.set_stroke_transform(DAffine2::IDENTITY);
// Reset the transform since we've applied it directly to the points
(result, DAffine2::IDENTITY)
})?;
@@ -1163,10 +1157,7 @@ fn auto_tangents<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
preserve_existing: bool,
) -> Result<(V::Live<'e>, Attr<TransformAttr>), Interrupt> {
let (source, transform) = map_vectors(ctx.arena(), source, *lane_transform, |source, transform| {
let mut result = Vector {
stroke: source.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut subpath in source.stroke_bezier_paths() {
subpath.apply_transform(transform);
@@ -1305,7 +1296,7 @@ fn bounding_box<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'sta
#[implementations(Graphic, Vector)] content: V,
) -> Result<V::Live<'e>, Interrupt> {
let (content, _) = map_vectors(ctx.arena(), content, DAffine2::IDENTITY, |vector, transform| {
let mut result = vector
let result = vector
.bounding_box_rect()
.map(|bbox| {
let mut vector = Vector::default();
@@ -1314,9 +1305,6 @@ fn bounding_box<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'sta
})
.unwrap_or_default();
result.stroke = vector.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
(result, transform)
})?;
@@ -1556,11 +1544,7 @@ fn offset_path<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'stat
let transform = Affine::new(transform_attribute.to_cols_array());
let bezpaths = vector.stroke_bezpath_iter();
let mut result = Vector {
stroke: vector.stroke.clone(),
..Default::default()
};
result.set_stroke_transform(DAffine2::IDENTITY);
let mut result = Vector { ..Default::default() };
// Perform operation on all subpaths in this shape.
for mut bezpath in bezpaths {
@@ -1593,16 +1577,23 @@ fn offset_path<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'stat
fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
// TODO: Make this node support stroke align, which it currently ignores
// A fill exists when the canonical attribute carries paint
let has_fills: Vec<bool> = (0..flattened.len()).map(|index| has_paint::<Fill, _>(&flattened, index)).collect();
// A fill exists when the row's appearance carries a painted fill coverage
let has_fills: Vec<bool> = (0..flattened.len())
.map(|index| {
flattened
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill))
})
.collect();
let output: List<Vector> = flattened
.into_iter()
.zip(has_fills)
.flat_map(|(row, has_fill)| {
let (mut vector, attributes) = row.into_parts();
let (vector, attributes) = row.into_parts();
let stroke = vector.stroke.clone().unwrap_or_default();
let appearance = attributes.get::<Appearance>(graphic_types::ATTR_APPEARANCE).cloned().unwrap_or_default();
let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default();
let bezpaths = vector.stroke_bezpath_iter();
let mut solidified_stroke = Vector::default();
@@ -1620,7 +1611,11 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths;
let miter_limit = stroke.join_miter_limit;
let paint_order = stroke.paint_order;
// The paint order rides the row's coverage order
let stroke_below = attributes
.get::<Appearance>(graphic_types::ATTR_APPEARANCE)
.map(Appearance::fill_and_stroke)
.is_some_and(|resolved| resolved.stroke_below);
let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap)
@@ -1649,26 +1644,32 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
solidified_stroke.append_bezpath(solidified);
}
// If the original vector has a fill, preserve it as a separate item with the stroke cleared.
// If the original vector has a fill, preserve it as a separate item with the stroke coverages dropped.
let fill_row = has_fill.then(|| {
vector.stroke = None;
let mut fill_attributes = attributes.clone();
// No stroke remains on the fill row
fill_attributes.remove::<Option<List<Graphic>>>(ATTR_STROKE);
if let Some(appearance) = fill_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) {
appearance.retain_cover(Cover::Fill);
}
Item::from_parts(vector, fill_attributes)
});
let mut stroke_attributes = attributes;
// Drop the original fill and use the stroke paint to fill the outlined stroke
stroke_attributes.remove::<Option<List<Graphic>>>(ATTR_FILL);
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
if let Some(appearance) = stroke_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) {
// The outlined stroke is filled with the stroke coverage's paint
let stroke_paint = appearance.first_paint_of(Cover::Stroke).cloned();
*appearance = Appearance::default();
if let Some(paint) = stroke_paint {
appearance.replace_or_insert(Coverage::new_fill(), paint, CoverPlacement::Above);
}
}
let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes);
// Ordering based on the paint order. The first item in the `List` is rendered below the second.
match paint_order {
PaintOrder::StrokeAbove => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
PaintOrder::StrokeBelow => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
// Ordering based on the coverage order. The first item in the `List` is rendered below the second.
match stroke_below {
false => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
true => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
}
})
.collect();
@@ -1689,8 +1690,7 @@ fn solidify_native_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1749,8 +1749,7 @@ fn emit_legacy_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1771,15 +1770,10 @@ fn emit_legacy_lane<'e>(
};
let element = output.element(lane).cloned().unwrap_or_default();
let fill = output
.attribute::<Option<List<Graphic>>>(ATTR_FILL, lane)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
let stroke = output
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, lane)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
let appearance = output
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, lane)
.cloned()
.map(|appearance| park_appearance(arena, appearance))
.transpose()?;
let layer_path: Vec<NodeId> = output.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, lane).cloned().unwrap_or_default();
let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0;
@@ -1792,8 +1786,7 @@ fn emit_legacy_lane<'e>(
Ok((
element,
Attr(output.attribute_cloned_or_default(ATTR_TRANSFORM, lane)),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(output.attribute_cloned_or_default(ATTR_BLEND_MODE, lane)),
Attr(output.attribute_cloned_or(ATTR_OPACITY, lane, 1.)),
Attr(output.attribute_cloned_or(ATTR_OPACITY_FILL, lane, 1.)),
@@ -1839,8 +1832,7 @@ fn solidify_stroke<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1879,8 +1871,7 @@ fn solidify_stroke_vector<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1925,7 +1916,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
return vec![row];
}
let stroke = row.element().stroke.clone();
let (_, attributes) = row.into_parts();
bezpaths
@@ -1933,7 +1923,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
.map(|bezpath| {
let mut vector = Vector::default();
vector.append_bezpath(bezpath);
vector.stroke = stroke.clone();
Item::from_parts(vector, attributes.clone())
})
@@ -1951,8 +1940,7 @@ fn separate_subpaths<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2007,8 +1995,7 @@ fn map_points<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2063,8 +2050,7 @@ fn flatten_path_core<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
Option<usize>,
@@ -2087,35 +2073,19 @@ fn flatten_path_core<'e>(
let source_transform = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index);
output.concat(element, source_transform, collision_hash_seed);
// TODO: Make this instead use the first encountered stroke
// Use the last encountered stroke as the output stroke
output.stroke = element.stroke.clone();
primary_source = Some((index, source_transform));
}
let mut fill = None;
let mut stroke = None;
// The primary row's appearance carries over whole, its paint transforms baked
let mut appearance = None;
let mut layer_path = Vec::new();
if let Some((primary, source_transform)) = primary_source {
let source_attributes = flattened.clone_item_attributes(primary);
let mut attributes = ItemAttributeValues::new();
attributes.insert_cloned_from(&source_attributes, ATTR_FILL);
attributes.insert_cloned_from(&source_attributes, ATTR_STROKE);
attributes.insert_cloned_from(&source_attributes, graphic_types::ATTR_APPEARANCE);
bake_paint_transforms(&mut attributes, source_transform);
let carrier = List::new_from_item(Item::from_parts(Vector::default(), attributes));
fill = carrier
.attribute::<Option<List<Graphic>>>(ATTR_FILL, 0)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
stroke = carrier
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, 0)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
appearance = attributes.remove::<Appearance>(graphic_types::ATTR_APPEARANCE).and_then(|appearance| appearance.declared().cloned());
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
layer_path = flattened.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, primary);
@@ -2131,11 +2101,12 @@ fn flatten_path_core<'e>(
// editor click-target preservation, as the boolean operation does.
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
let appearance = appearance.map(|appearance| park_appearance(arena, appearance)).transpose()?;
Ok((
output,
Attr(DAffine2::IDENTITY),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(layer_path.as_slice()),
Attr(Some(merged_layers)),
primary_source.map(|(index, _)| index),
@@ -2151,8 +2122,7 @@ pub fn combine_paths<'e>(
(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
),
@@ -2164,12 +2134,12 @@ pub fn combine_paths<'e>(
let item = content.as_group_item();
let (flattened, tops) = flatten_rows_with_top_lanes(graphic_types::graphic::GraphicLevel::Run(&item));
let snapshot = graphic_types::graphic::run_to_list::<Graphic>(&item).expect("the run holds the row's element type");
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
let (element, transform, appearance, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
// The merge presents the blending of the top-level row its last contributing
// path came from, carried rather than re-read. The layer path stays an
// override: it deliberately names the contributing CHILD layer, not the row.
let carrier = primary.and_then(|row| tops.get(row).copied()).unwrap_or(0);
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
Ok((content.lane(carrier).map_element(element), transform, appearance, layer_path, merged))
}
/// The path flattening over a plain vector level, as [`combine_paths`].
@@ -2182,8 +2152,7 @@ pub fn combine_paths_vector<'e>(
(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
),
@@ -2195,12 +2164,12 @@ pub fn combine_paths_vector<'e>(
let wrapper = wrap_vector_level(content);
let flattened = graphic_types::graphic::flatten_vector_rows(graphic_types::graphic::GraphicLevel::Legacy(&wrapper));
let snapshot = legacy_graphic_list_of(content);
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
let (element, transform, appearance, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
// `top_lane` is degenerate here - the wrapper is one graphic lane holding the
// whole vector run, so every row reports 0. The rows ARE the input lanes in
// order though, so the contributing row index names the lane directly.
let carrier = primary.filter(|row| *row < content.len()).unwrap_or(0);
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
Ok((content.lane(carrier).map_element(element), transform, appearance, layer_path, merged))
}
pub use _combine_paths_vector_mod::combine_paths_vector_entries;
@@ -2235,16 +2204,13 @@ fn sample_polyline<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + '
}
};
let mut element = element;
let mut result = Vector {
point_domain: Default::default(),
segment_domain: Default::default(),
region_domain: Default::default(),
colinear_manipulators: Default::default(),
stroke: std::mem::take(&mut element.stroke),
};
// Transfer the stroke transform from the input vector content to the result.
result.set_stroke_transform(transform);
for local_bezpath in element.stroke_bezpath_iter() {
// Apply the transform to compute sample locations in world space (for correct distance-based spacing)
@@ -2311,10 +2277,7 @@ fn simplify<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: content.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -2405,10 +2368,7 @@ fn decimate<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: content.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -2485,10 +2445,7 @@ fn cut_path_core(mut content: List<Vector>, progression: f64, reverse: bool, par
let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize };
if let Some((row_index, bezpath)) = bezpaths.get(index).cloned() {
let mut result_vector = Vector {
stroke: content.element(row_index).unwrap().stroke.clone(),
..Default::default()
};
let mut result_vector = Vector::default();
for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) {
result_vector.append_bezpath(bezpath.clone());
@@ -2527,8 +2484,7 @@ fn cut_path<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2738,8 +2694,6 @@ fn scatter_points<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 's
}
// Transfer the style from the input vector content to the result.
result.stroke = element.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
(result, lane)
})?;
@@ -2933,7 +2887,6 @@ fn offset_points<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
///
/// *Progression* morphs through all objects. Interpolation is linear unless *Path* geometry is provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments.
fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progression: f64, reverse: bool, distribution: InterpolationDistribution, path: List<Vector>) -> List<Vector> {
use core_types::lane::LaneSource;
/// Promotes a segment's handle pair to cubic-equivalent Bézier control points.
/// For linear segments (both None), handles are placed at their respective anchors (zero-length)
/// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature.
@@ -3092,6 +3045,51 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
}
}
/// Lerps two appearances pairing coverages by cover, so a fill and a stroke never interpolate into each other.
/// Stroke parameter pairs interpolate; other coverage pairings and the paint order step at the midpoint.
fn lerp_appearance(a: Option<&Appearance>, b: Option<&Appearance>, time: f64) -> Option<Appearance> {
if a.is_none() && b.is_none() {
return None;
}
let empty = Appearance::default();
let (a, b) = (a.unwrap_or(&empty), b.unwrap_or(&empty));
// The side holding the paint order at this time leads, so covers only the other side has follow behind it
let (leading, trailing) = if time < 0.5 { (a, b) } else { (b, a) };
let mut covers: Vec<Cover> = Vec::new();
for cover in leading.covers().chain(trailing.covers()).map(Coverage::cover) {
if !covers.contains(&cover) {
covers.push(cover);
}
}
let mut result = Appearance::default();
for cover in covers {
let (source_index, target_index) = (a.first_index_of(cover), b.first_index_of(cover));
// An unmatched stroke steps out at the midpoint, matching the stroke geometry, while an unmatched fill persists and fades
let coverage = match (source_index.and_then(|index| a.cover_at(index)), target_index.and_then(|index| b.cover_at(index))) {
(Some(source), Some(target)) if cover == Cover::Stroke => Coverage::new_stroke(&source.stroke_params().lerp(&target.stroke_params(), time)),
(Some(source), Some(target)) => (if time < 0.5 { source } else { target }).clone(),
(Some(_), None) if cover == Cover::Stroke && time >= 0.5 => continue,
(None, Some(_)) if cover == Cover::Stroke && time < 0.5 => continue,
(Some(source), None) => source.clone(),
(None, Some(target)) => target.clone(),
(None, None) => continue,
};
// An unmatched side falls to `None` here, which `lerp_graphic` fades against transparent.
// The paint cell carries its graphic list as one wrapped cell, so the lerp works on the unwrapped rows.
let source_paint = source_index.and_then(|index| a.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let target_paint = target_index.and_then(|index| b.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let paint = lerp_graphic(source_paint, target_paint, time).map(Graphic::Graphic).unwrap_or_default();
result.replace_or_insert(coverage, paint, CoverPlacement::Above);
}
Some(result)
}
// Preserve the original legacy snapshot as upstream data so this group layer's nested layers can be edited by the tools.
let mut graphic_list_content = snapshot;
@@ -3349,35 +3347,12 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
return List::new_from_item(Item::from_parts(endpoint_element.clone(), attributes));
}
let stroke = match (source_element.stroke.as_ref(), target_element.stroke.as_ref()) {
(Some(a), Some(b)) => Some(a.lerp(b, time)),
(Some(a), None) => {
if time < 0.5 {
Some(a.clone())
} else {
None
}
}
(None, Some(b)) => {
if time < 0.5 {
None
} else {
Some(b.clone())
}
}
(None, None) => None,
};
let mut vector = Vector { stroke, ..Default::default() };
let mut vector = Vector::default();
let fill_paint = {
let source = content.attr::<Fill>(source_index).filter(|paint| is_paint_present(paint));
let target = content.attr::<Fill>(target_index).filter(|paint| is_paint_present(paint));
lerp_graphic(source, target, time)
};
let stroke_paint = {
let source = content.attr::<StrokeAttr>(source_index).filter(|paint| is_paint_present(paint));
let target = content.attr::<StrokeAttr>(target_index).filter(|paint| is_paint_present(paint));
lerp_graphic(source, target, time)
let appearance = {
let source = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, source_index);
let target = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, target_index);
lerp_appearance(source, target, time)
};
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
@@ -3537,11 +3512,8 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
.with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content));
if let Some(fill) = fill_paint {
item.set_attribute(ATTR_FILL, Some(fill));
}
if let Some(stroke) = stroke_paint {
item.set_attribute(ATTR_STROKE, Some(stroke));
if let Some(appearance) = appearance {
item.set_attribute(graphic_types::ATTR_APPEARANCE, appearance);
}
List::new_from_item(item)
@@ -3562,8 +3534,7 @@ fn morph_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -3602,8 +3573,7 @@ fn morph<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -3634,8 +3604,7 @@ fn morph_vector<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -4081,7 +4050,6 @@ fn centroid(_: impl Ctx, vector: IList<Vector>, centroid_type: CentroidType) ->
#[cfg(test)]
mod test {
use super::*;
use graphic_types::graphic::paint_graphics;
use kurbo::{CubicBez, Ellipse, Point, Rect};
use vector_types::vector::algorithms::bezpath_algorithms::{TValue, trim_pathseg};
use vector_types::vector::misc::pathseg_abs_diff_eq;
@@ -4376,12 +4344,13 @@ mod test {
v
};
let fill_appearance = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(List::new_from_element(color).into_graphic_list()));
let item_a = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
.with_attribute(ATTR_FILL, Some(List::new_from_element(Color::RED).into_graphic_list()));
.with_attribute(graphic_types::ATTR_APPEARANCE, fill_appearance(Color::RED));
let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_FILL, Some(List::new_from_element(Color::BLUE).into_graphic_list()));
.with_attribute(graphic_types::ATTR_APPEARANCE, fill_appearance(Color::BLUE));
let mut content = List::new_from_item(item_a);
content.push(item_b);
@@ -4389,7 +4358,13 @@ mod test {
let snapshot = content.into_graphic_list();
let morphed = super::morph_core(snapshot.clone().into_flattened_list(), snapshot, 0.5, false, InterpolationDistribution::default(), List::default());
let fill = paint_graphics::<Fill, _>(&morphed, 0).expect("Morph should keep the fill paint at the midpoint");
let appearance = morphed
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, 0)
.expect("Morph should keep the appearance at the midpoint");
let fill = appearance
.first_paint_of(Cover::Fill)
.and_then(graphic_types::graphic::paint_cell_rows)
.expect("Morph should keep the fill paint at the midpoint");
// Interpolated color between red and blue should have >0 value on both R and B
let Some(Graphic::Color(color)) = fill.element(0) else {