mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 22:28:10 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
421
node-graph/libraries/graphic-types/src/appearance.rs
Normal file
421
node-graph/libraries/graphic-types/src/appearance.rs
Normal file
@@ -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<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` 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<Cover>);
|
||||
|
||||
// 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<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 {
|
||||
// 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::<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.)),
|
||||
..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));
|
||||
}
|
||||
}
|
||||
@@ -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<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 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::<Option<Appearance>>().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<Option<Box<dyn core_types::list::AnyAttributeValue>>> {
|
||||
let appearance = value.as_any().downcast_ref::<Option<Appearance>>().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::<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::<Option<Appearance>>(deep_clone_appearance, deep_repark_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>()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Graphic<'static>>>;
|
||||
/// 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::<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]
|
||||
|
||||
@@ -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<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
|
||||
@@ -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::<crate::gradient::GradientType>());
|
||||
assert_eq!(info("spread_method").unwrap().value_type, TypeId::of::<crate::gradient::GradientSpreadMethod>());
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user