From 76a6bae5287db6cbbf34ebfdd14b93d759b3ef70 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 9 Sep 2026 20:14:40 +0000 Subject: [PATCH] Add the appearance model's types, markers, and deep field glue Cover, Coverage, and Appearance mirror the upstream appearance model on the record substrate with a 'static interior: the paint column stores Graphic<'static>, and the appearance marker's value is Option<&Appearance>, exactly as the Fill paint marker's is today. Stroke parameters ride the coverage item as attributes elided at their defaults, with typed markers whose defaults are pinned to Stroke::default() by test. The deep field glue carries paint-held groups across the owned, resident, and persistent seams as the paint list glue does. Co-Authored-By: Claude Fable 5 --- .../libraries/graphic-types/src/appearance.rs | 421 ++++++++++++++++++ .../graphic-types/src/graphic/glue.rs | 79 ++++ node-graph/libraries/graphic-types/src/lib.rs | 4 +- .../libraries/graphic-types/src/markers.rs | 10 + .../libraries/vector-types/src/markers.rs | 39 ++ 5 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 node-graph/libraries/graphic-types/src/appearance.rs diff --git a/node-graph/libraries/graphic-types/src/appearance.rs b/node-graph/libraries/graphic-types/src/appearance.rs new file mode 100644 index 0000000000..b07dc72320 --- /dev/null +++ b/node-graph/libraries/graphic-types/src/appearance.rs @@ -0,0 +1,421 @@ +//! 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` so columnar presence holds, +//! while cover-specific data rides the inner `Item`, reusing `ATTR_TRANSFORM` for the stroke-authoring space. +//! +//! The interior is `'static` in this first form: the paint column stores `Graphic<'static>`, exactly as the +//! `Fill` marker's paint list does today. Native-resident interiors are the recorded follow-up. + +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); + +// Item equality ignores attributes, but the stroke parameters live there, so both impls walk the +// attribute pairs in the erased display form, the same comparison `AttributeValueDyn` uses. +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(&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); + +/// 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 { + // A single walk of the attribute pairs instead of one keyed scan per parameter, since this runs per item per render pass + let mut stroke = Stroke::default(); + for (key, value) in self.0.attributes().iter() { + 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::>() + .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 { + 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 { + 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::>(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 { + 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>)> { + 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, + 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(item: &mut Item, coverage: Coverage, paint: Graphic<'static>, placement: CoverPlacement) { + item.attribute_mut_or_insert_default::(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 { + 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.)), + ..Default::default() + }; + + 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)); + } +} diff --git a/node-graph/libraries/graphic-types/src/graphic/glue.rs b/node-graph/libraries/graphic-types/src/graphic/glue.rs index 799344e541..64ec258b33 100644 --- a/node-graph/libraries/graphic-types/src/graphic/glue.rs +++ b/node-graph/libraries/graphic-types/src/graphic/glue.rs @@ -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}; @@ -330,12 +331,90 @@ 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 { + 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::(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::(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::(&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::(&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 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> { + let appearance = value.as_any().downcast_ref::>().expect("an appearance field deep-copies at its own type"); + let appearance = appearance.as_ref().filter(|appearance| appearance_contains_groups(appearance))?; + let mut appearance = appearance.clone(); + map_appearance_groups_to_owned(&mut appearance); + Some(Box::new(Some(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>> { + let appearance = value.as_any().downcast_ref::>().expect("an appearance field replays at its own type"); + let Some(appearance) = appearance.as_ref().filter(|appearance| appearance_contains_groups(appearance)) else { + return Some(None); + }; + let mut appearance = appearance.clone(); + map_appearance_groups_to_resident(&mut appearance, arena)?; + Some(Some(Box::new(Some(appearance)))) +} + const _: () = { fn register_all() { core_types::record::register_deep_element_clone::(deep_clone_graphic, deep_repark_graphic); core_types::record::register_deep_field_value::>>(deep_clone_graphic_list, deep_repark_graphic_list); core_types::record::register_field_promote::>>>(promote_graphic_list); core_types::record::register_element_promote::(promote_graphic); + core_types::record::register_deep_field_value::>(deep_clone_appearance, deep_repark_appearance); + core_types::record::register_retained_heap::(|value| value.downcast_ref::().map_or(0, appearance_retained_heap)); core_types::record::register_retained_heap::(|value| value.downcast_ref::().map_or(0, graphic_retained_heap)); core_types::record::register_retained_heap::(|value| value.downcast_ref::().map_or(0, vector_retained_heap)); core_types::record::register_retained_heap::>(|value| value.downcast_ref::>().map_or(0, |raster| raster.data.len() * size_of::())); diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 8d920b6a05..560a7e417b 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -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_FILL, ATTR_PAINT, ATTR_STROKE}; pub mod migrations { use crate::Vector; diff --git a/node-graph/libraries/graphic-types/src/markers.rs b/node-graph/libraries/graphic-types/src/markers.rs index 12bc494b99..e0bfdae910 100644 --- a/node-graph/libraries/graphic-types/src/markers.rs +++ b/node-graph/libraries/graphic-types/src/markers.rs @@ -20,11 +20,19 @@ core_types::attribute! { /// 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>>; + /// 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. + pub Appearance("appearance"): Option<&crate::appearance::Appearance>; + /// One coverage row's paint, a bare graphic riding the coverage list as a column. + /// Absent when the coverage paints nothing. + pub Paint("paint"): Option<&Graphic<'static>>; } pub const ATTR_FILL: &str = Fill::NAME; pub const ATTR_STROKE: &str = Stroke::NAME; 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 { @@ -37,6 +45,8 @@ mod tests { for name in ["fill", "stroke", "editor:merged_layers"] { assert_eq!(info(name).unwrap().value_type, TypeId::of::>>()); } + assert_eq!(info("appearance").unwrap().value_type, TypeId::of::>()); + assert_eq!(info("paint").unwrap().value_type, TypeId::of::>()); } #[test] diff --git a/node-graph/libraries/vector-types/src/markers.rs b/node-graph/libraries/vector-types/src/markers.rs index 54d3779e9e..897420afe2 100644 --- a/node-graph/libraries/vector-types/src/markers.rs +++ b/node-graph/libraries/vector-types/src/markers.rs @@ -13,6 +13,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>; + /// 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 @@ -25,6 +39,13 @@ core_types::named_value! { pub const ATTR_SPREAD_METHOD: &str = SpreadMethod::NAME; pub const ATTR_GRADIENT_TYPE: &str = GradientType::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 { @@ -37,6 +58,24 @@ mod tests { assert_eq!(info("gradient_type").unwrap().value_type, TypeId::of::()); assert_eq!(info("spread_method").unwrap().value_type, TypeId::of::()); assert_eq!(info("editor:click_target").unwrap().value_type, TypeId::of::>()); + assert_eq!(info("weight").unwrap().value_type, TypeId::of::()); + assert_eq!(info("dash_pattern").unwrap().value_type, TypeId::of::>>()); + assert_eq!(info("dash_offset").unwrap().value_type, TypeId::of::()); + assert_eq!(info("cap").unwrap().value_type, TypeId::of::()); + assert_eq!(info("join").unwrap().value_type, TypeId::of::()); + assert_eq!(info("join_miter_limit").unwrap().value_type, TypeId::of::()); + assert_eq!(info("align").unwrap().value_type, TypeId::of::()); + } + + #[test] + fn stroke_parameter_defaults_match_the_stroke_struct() { + let defaults = crate::vector::style::Stroke::default(); + assert_eq!(::default(), defaults.weight); + assert_eq!(::default(), defaults.dash_offset); + assert_eq!(::default(), defaults.join_miter_limit); + assert_eq!(::default(), defaults.cap); + assert_eq!(::default(), defaults.join); + assert_eq!(::default(), defaults.align); } #[test]