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

* Add the appearance model types and attribute constants

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

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

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

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

* Code review fixes

* Re-save demo art

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

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

* Update demo art

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

View File

@@ -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, CoverPlacement, Coverage, Graphic, Vector, stamp_coverage};
pub use math_nodes;
pub use path_bool_nodes;
pub use raster_nodes;

View File

@@ -44,7 +44,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],
@@ -61,7 +61,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>,
@@ -86,7 +86,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,
@@ -363,7 +363,7 @@ pub async fn render_output_cache<'a: 'n>(
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

@@ -47,7 +47,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
let footprint = Footprint::default();
let mut metadata = RenderMetadata::default();
data.collect_metadata(&mut metadata, footprint, None);
data.collect_metadata(&mut metadata, footprint, None, None);
let intermediate = match &render_params.render_output_type {
RenderOutputTypeRequest::Vello => {
let mut scene = vello::Scene::new();

View File

@@ -1,6 +1,7 @@
use core_types::list::{Item, List, NodeIdPath};
use core_types::list::{ATTR_APPEARANCE, Item, List, NodeIdPath};
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Ctx};
use glam::{DAffine2, DVec2};
use graphic_types::Appearance;
use graphic_types::graphic::bake_paint_transforms;
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
use graphic_types::vector_types::vector::PointId;
@@ -45,7 +46,15 @@ async fn boolean_operation<I: graphic_types::IntoGraphicList>(
let result_vector = result_vector_list.element_mut(0).unwrap();
Vector::transform(result_vector, transform);
result_vector.set_stroke_transform(DAffine2::IDENTITY);
// The geometry is baked into identity space, so a copied stroke authoring space would be stale
if let Some(appearance) = result_vector_list.attribute::<Appearance>(ATTR_APPEARANCE, 0) {
let mut appearance = appearance.clone();
for coverage in appearance.0.iter_element_values_mut() {
coverage.0.remove_attribute::<DAffine2>(ATTR_TRANSFORM);
}
result_vector_list.set_attribute(ATTR_APPEARANCE, 0, appearance);
}
// Snapshot the input layers as the `editor:merged_layers` attribute so the renderer can recurse into them
// for editor click-target preservation.
@@ -142,12 +151,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()
};
@@ -191,6 +195,7 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
let parent_opacity: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let parent_fill: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let layer_path: NodeIdPath = graphic_list.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index);
let parent_appearance = graphic_list.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared).cloned();
let compose_parent = |mut item: Item<Vector>| {
if parent_has_transform || item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some() {
@@ -208,6 +213,12 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
if parent_has_layer_path {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
}
// Appearance cascades into each child whose own is undeclared, since a declared child wins wholesale
if let Some(appearance) = &parent_appearance
&& item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).is_none()
{
item.set_attribute(ATTR_APPEARANCE, appearance.clone());
}
item
};
@@ -231,6 +242,13 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
*fill *= parent_fill;
}
}
if let Some(appearance) = &parent_appearance {
for value in graphic.iter_attribute_values_mut_or_default::<Appearance>(ATTR_APPEARANCE) {
if value.is_empty() {
*value = appearance.clone();
}
}
}
// Unioning each group lets it enter the outer operation as one region, which is why this cannot defer
// to `flatten_graphic_list`. Splicing children in as sibling operands makes them subtract from each other.

View File

@@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU};
use core::hash::{Hash, Hasher};
use core_types::blending::BlendMode;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath};
use core_types::list::{ATTR_APPEARANCE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath};
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
use core_types::transform::{Footprint, Transform};
use core_types::uuid::NodeId;
@@ -13,9 +13,9 @@ use core_types::{
};
use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Vector;
use graphic_types::graphic::{bake_paint_transforms, graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute_at};
use graphic_types::graphic::{bake_paint_transforms, is_paint_present};
use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{Graphic, IntoGraphicList};
use graphic_types::{Appearance, Cover, CoverPlacement, Coverage, Graphic, IntoGraphicList, stamp_coverage};
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
use rand::{Rng, SeedableRng};
@@ -32,7 +32,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, GradientSettings, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::vector::{PointDomain, RegionDomain};
@@ -72,8 +72,6 @@ impl VectorListIterMut for List<Vector> {
/// single `Item<Vector>` or `Item<Graphic>`.
trait VectorItemMut {
fn for_each_vector_mut(&mut self, f: impl FnMut(&mut Vector, DAffine2));
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>);
}
impl VectorItemMut for Item<Vector> {
@@ -81,10 +79,6 @@ impl VectorItemMut for Item<Vector> {
let transform = self.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
f(self.element_mut(), transform);
}
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
self.set_attribute(key, paint);
}
}
impl VectorItemMut for Item<Graphic> {
@@ -95,13 +89,6 @@ impl VectorItemMut for Item<Graphic> {
f(vector, *transform);
}
}
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
let Some(vector_list) = self.element_mut().as_vector_mut() else { return };
for slot in vector_list.iter_attribute_values_mut_or_default::<List<Graphic>>(key) {
*slot = paint.clone();
}
}
}
/// Geometry counterpart to [`VectorItemMut`] for the element-wise modifier nodes, running a per-item transformation over
@@ -252,13 +239,24 @@ where
};
let color = evaluator.evaluate(factor);
let paint = List::new_from_element(color).into_graphic_list();
let color_paint = List::new_from_element(color).into_graphic_list();
if fill {
set_paint_attribute_at(vector_list, index, ATTR_FILL, paint.clone());
vector_list.with_attribute_mut_or_default::<Appearance, _, _>(ATTR_APPEARANCE, index, |appearance| {
if !appearance.set_paint_of(Cover::Fill, color_paint.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), color_paint.clone(), CoverPlacement::Below);
}
});
}
if stroke && vector_list.element(index).is_some_and(|vector| vector.stroke.is_some()) {
set_paint_attribute_at(vector_list, index, ATTR_STROKE, paint.clone());
// The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
if stroke
&& vector_list
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_cover(Cover::Stroke))
{
vector_list.with_attribute_mut_or_default::<Appearance, _, _>(ATTR_APPEARANCE, index, |appearance| {
appearance.set_paint_of(Cover::Stroke, color_paint.clone());
});
}
i += 1;
@@ -339,7 +337,8 @@ where
}
}
content.set_vector_paint(ATTR_FILL, fill);
// Appending follows the painter's algorithm: the most downstream paint node in the chain paints on top
stamp_coverage(&mut content, Coverage::new_fill(), fill, CoverPlacement::Above);
content
}
@@ -370,9 +369,6 @@ async fn stroke<V, P: IntoGraphicList + 'n + Send + 'static>(
/// 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: Item<f64>,
// <https://svgwg.org/svg2-draft/painting.html#PaintOrderProperty>
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
paint_order: Item<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: Item<DashPattern>,
/// The phase offset distance from the starting point of the dash pattern.
@@ -383,13 +379,12 @@ where
Item<V>: VectorItemMut + 'n + Send,
{
let mut content = content;
let (weight, align, cap, join, miter_limit, paint_order, dash_offset) = (
let (weight, align, cap, join, miter_limit, dash_offset) = (
weight.into_element(),
align.into_element(),
cap.into_element(),
join.into_element(),
miter_limit.into_element(),
paint_order.into_element(),
dash_offset.into_element(),
);
let dash_lengths = dash_pattern.into_element().clamped_lengths();
@@ -403,17 +398,17 @@ where
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
content.for_each_vector_mut(|vector, transform| {
let mut stroke = stroke.clone();
stroke.transform *= transform;
vector.stroke = Some(stroke);
});
let paint = paint.into_graphic_list();
content.set_vector_paint(ATTR_STROKE, paint);
// The coverage records the stroke's authoring space, so the item transform is composed in. Its translation
// cancels out in every consumer, so it is cleared to let an otherwise-identity capture elide.
let mut coverage_stroke = stroke;
coverage_stroke.transform *= content.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
coverage_stroke.transform.translation = DVec2::ZERO;
stamp_coverage(&mut content, Coverage::new_stroke(&coverage_stroke), paint, CoverPlacement::Above);
content
}
@@ -543,10 +538,7 @@ async fn round_corners<V: MapVectorItems + 'n + Send>(
// 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();
// 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());
@@ -923,8 +915,6 @@ async fn box_warp<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementations(
});
}
result.set_stroke_transform(DAffine2::IDENTITY);
// Reset the transform since we've applied it directly to the points
*row.element_mut() = result;
row.set_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY);
@@ -1086,10 +1076,7 @@ async fn auto_tangents<V: MapVectorItems + 'n + Send>(
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
let (source, attributes) = source.into_parts();
let mut result = Vector {
stroke: source.stroke.clone(),
..Default::default()
};
let mut result = Vector::default();
for mut subpath in source.stroke_bezier_paths() {
subpath.apply_transform(transform);
@@ -1225,7 +1212,7 @@ async fn auto_tangents<V: MapVectorItems + 'n + Send>(
async fn bounding_box<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item<V>) -> Item<V> {
V::map_vector_items(content, |content| {
let mut content = content;
let mut result = content
let result = content
.element()
.bounding_box_rect()
.map(|bbox| {
@@ -1235,9 +1222,6 @@ async fn bounding_box<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementati
})
.unwrap_or_default();
result.stroke = std::mem::take(&mut content.element_mut().stroke);
result.set_stroke_transform(DAffine2::IDENTITY);
*content.element_mut() = result;
content
})
@@ -1481,11 +1465,7 @@ async fn offset_path<V: MapVectorItems + 'n + Send>(
let vector = std::mem::take(content.element_mut());
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();
// Perform operation on all subpaths in this shape.
for mut bezpath in bezpaths {
@@ -1568,16 +1548,28 @@ fn solidify_stroke_list_with_snapshot(source: List<Vector>) -> List<Vector> {
/// Replaces each item's stroke with filled outline geometry, emitting a separate fill item beside it when the
/// original carried a fill. Grows the item count by up to 2x.
fn solidify_stroke_list(content: List<Vector>) -> List<Vector> {
// A fill exists when the canonical attribute carries paint
let has_fills: Vec<bool> = (0..content.len()).map(|index| has_paint_at(&content, index, ATTR_FILL)).collect();
// A fill exists when its coverage carries paint that draws something
let has_fills: Vec<bool> = (0..content.len())
.map(|index| {
content
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill))
})
.collect();
content
.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>(ATTR_APPEARANCE).cloned().unwrap_or_default();
let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default();
// List order is paint order: a stroke coverage before the first fill coverage paints below it
let stroke_below = appearance
.first_index_of(Cover::Stroke)
.zip(appearance.first_index_of(Cover::Fill))
.is_some_and(|(stroke_index, fill_index)| stroke_index < fill_index);
let bezpaths = vector.stroke_bezpath_iter();
let mut solidified_stroke = Vector::default();
@@ -1593,9 +1585,8 @@ fn solidify_stroke_list(content: List<Vector>) -> List<Vector> {
StrokeCap::Square => kurbo::Cap::Square,
};
let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths;
let dash_pattern = stroke.dash_lengths.clone();
let miter_limit = stroke.join_miter_limit;
let paint_order = stroke.paint_order;
let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap)
@@ -1624,26 +1615,29 @@ fn solidify_stroke_list(content: 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::<List<Graphic>>(ATTR_STROKE);
if let Some(mut appearance) = fill_attributes.remove::<Appearance>(ATTR_APPEARANCE) {
appearance.retain_cover(Cover::Fill);
fill_attributes.insert(ATTR_APPEARANCE, appearance);
}
Item::from_parts(vector, fill_attributes)
});
// The outlined stroke geometry becomes a fill painted with the original stroke's paint
let mut stroke_attributes = attributes;
// Drop the original fill and use the stroke paint to fill the outlined stroke
stroke_attributes.remove::<List<Graphic>>(ATTR_FILL);
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
if stroke_attributes.remove::<Appearance>(ATTR_APPEARANCE).is_some() {
let stroke_paint = appearance.first_index_of(Cover::Stroke).and_then(|index| appearance.paint_at(index).cloned()).unwrap_or_default();
stroke_attributes.insert(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), stroke_paint));
}
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<_>>(),
// 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()
@@ -1667,7 +1661,6 @@ async fn separate_subpaths<V: ExpandVectorItems + 'n + Send>(_: impl Ctx, #[impl
return List::new_from_item(content);
}
let stroke = content.element().stroke.clone();
let (_, attributes) = content.into_parts();
bezpaths
@@ -1675,7 +1668,6 @@ async fn separate_subpaths<V: ExpandVectorItems + 'n + Send>(_: impl Ctx, #[impl
.map(|bezpath| {
let mut vector = Vector::default();
vector.append_bezpath(bezpath);
vector.stroke = stroke.clone();
Item::from_parts(vector, attributes.clone())
})
@@ -1743,10 +1735,6 @@ pub async fn combine_paths<T: IntoGraphicList>(_: impl Ctx, #[implementations(Li
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));
}
@@ -1754,8 +1742,7 @@ pub async fn combine_paths<T: IntoGraphicList>(_: impl Ctx, #[implementations(Li
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, ATTR_APPEARANCE);
// Adopt the last input item's layer (if any) so the editor can also bucket clicks under a contributing child layer
attributes.insert_cloned_from(&source_attributes, ATTR_EDITOR_LAYER_PATH);
bake_paint_transforms(&mut attributes, source_transform);
@@ -1806,15 +1793,7 @@ async fn sample_polyline<V: MapVectorItems + 'n + Send>(
}
};
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 content.element_mut().stroke),
};
// Transfer the stroke transform from the input vector content to the result.
result.set_stroke_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM));
let mut result = Vector::default();
for local_bezpath in content.element().stroke_bezpath_iter() {
// Apply the transform to compute sample locations in world space (for correct distance-based spacing)
@@ -1884,10 +1863,7 @@ async fn simplify<V: MapVectorItems + 'n + Send>(
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: std::mem::take(&mut item.element_mut().stroke),
..Default::default()
};
let mut result = Vector::default();
for mut bezpath in item.element().stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -1981,10 +1957,7 @@ async fn decimate<V: MapVectorItems + 'n + Send>(
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: std::mem::take(&mut content.element_mut().stroke),
..Default::default()
};
let mut result = Vector::default();
for mut bezpath in content.element().stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -2060,10 +2033,7 @@ async fn cut_path<V: MapVectorItems + 'n + Send>(
let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize };
if let Some(bezpath) = bezpaths.get(index).cloned() {
let mut result_vector = Vector {
stroke: content.element().stroke.clone(),
..Default::default()
};
let mut result_vector = Vector::default();
for (_, bezpath) in bezpaths.iter().enumerate().filter(|&(i, _)| i != index) {
result_vector.append_bezpath(bezpath.clone());
@@ -2273,10 +2243,6 @@ async fn scatter_points<V: MapVectorItems + 'n + Send>(
}
}
// Transfer the style from the input vector content to the result.
result.stroke = content.element().stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
*content.element_mut() = result;
content
})
@@ -2662,6 +2628,48 @@ async fn morph<I: IntoGraphicList>(
graphic.map(List::new_from_element)
}
// Lerp between 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
let paint = lerp_graphic(source_index.and_then(|index| a.paint_at(index)), target_index.and_then(|index| b.paint_at(index)), time).unwrap_or_default();
result.replace_or_insert(coverage, paint, CoverPlacement::Above);
}
Some(result)
}
let (progression, reverse, distribution) = (progression.into_element(), reverse.into_element(), distribution.into_element());
// Preserve original `List<Graphic>` as upstream data so this group layer's nested layers can be edited by the tools.
@@ -2916,35 +2924,12 @@ async fn morph<I: IntoGraphicList>(
return 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 = graphic_list_at(&content, source_index, ATTR_FILL);
let target = graphic_list_at(&content, target_index, ATTR_FILL);
lerp_graphic(source.as_deref(), target.as_deref(), time)
};
let stroke_paint = {
let source = graphic_list_at(&content, source_index, ATTR_STROKE);
let target = graphic_list_at(&content, target_index, ATTR_STROKE);
lerp_graphic(source.as_deref(), target.as_deref(), time)
let appearance = {
let source = content.attribute::<Appearance>(ATTR_APPEARANCE, source_index);
let target = content.attribute::<Appearance>(ATTR_APPEARANCE, target_index);
lerp_appearance(source, target, time)
};
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
@@ -3114,11 +3099,8 @@ async fn morph<I: IntoGraphicList>(
}
item.set_attribute(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content);
if let Some(fill) = fill_paint {
item.set_attribute(ATTR_FILL, fill);
}
if let Some(stroke) = stroke_paint {
item.set_attribute(ATTR_STROKE, stroke);
if let Some(appearance) = appearance {
item.set_attribute(ATTR_APPEARANCE, appearance);
}
item
@@ -3904,12 +3886,13 @@ mod test {
v
};
let solid_fill = |color: Color| Appearance::new_single(Coverage::new_fill(), 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, List::new_from_element(Color::RED).into_graphic_list());
.with_attribute(ATTR_APPEARANCE, solid_fill(Color::RED));
let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_FILL, List::new_from_element(Color::BLUE).into_graphic_list());
.with_attribute(ATTR_APPEARANCE, solid_fill(Color::BLUE));
let mut content = List::new_from_item(item_a);
content.push(item_b);
@@ -3925,7 +3908,8 @@ mod test {
.await;
let morphed = List::new_from_item(morphed);
let fill = graphic_list_at(&morphed, 0, ATTR_FILL).expect("Morph should keep the fill paint at the midpoint");
let appearance = morphed.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint");
let fill = appearance.first_paint_of(Cover::Fill).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(colors)) = fill.element(0) else {
@@ -3935,6 +3919,64 @@ mod test {
assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}");
}
#[tokio::test]
async fn morph_pairs_appearance_coverages_by_cover() {
let rect = || {
let mut v = Vector::default();
v.append_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY));
v
};
let paint_color = |appearance: &Appearance, cover| {
let paint = appearance.first_paint_of(cover).expect("Morph should keep both paints at the midpoint");
let Some(Graphic::Color(colors)) = paint.element(0) else {
panic!("Expected a solid color paint, got {:?}", paint.element(0));
};
*colors.element(0).expect("Color present")
};
// The two endpoints list their covers in opposite paint orders, which pairing by position would cross
let appearance = |fill: Color, stroke: Color, stroke_placement| {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), List::new_from_element(fill).into_graphic_list(), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(4.)), List::new_from_element(stroke).into_graphic_list(), stroke_placement);
appearance
};
let item_a = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
.with_attribute(ATTR_APPEARANCE, appearance(Color::RED, Color::BLACK, CoverPlacement::Above));
let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_APPEARANCE, appearance(Color::BLUE, Color::WHITE, CoverPlacement::Below));
let mut content = List::new_from_item(item_a);
content.push(item_b);
let morphed = super::morph(
Footprint::default(),
content,
Item::new_from_element(0.5),
Item::new_from_element(false),
Item::new_from_element(InterpolationDistribution::default()),
Item::default(),
)
.await;
let morphed = List::new_from_item(morphed);
let appearance = morphed.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint");
assert_eq!(appearance.len(), 2, "the midpoint should hold one fill and one stroke, not a duplicated cover");
let fill = paint_color(appearance, Cover::Fill);
assert!(fill.r() > 0. && fill.b() > 0. && fill.g() == 0., "the fill should interpolate against the other fill, got {fill:?}");
let stroke = paint_color(appearance, Cover::Stroke);
assert!(
stroke.r() > 0. && stroke.r() == stroke.g() && stroke.g() == stroke.b(),
"the stroke should interpolate against the other stroke, got {stroke:?}"
);
}
#[track_caller]
fn contains_segment(vector: Vector, target: PathSeg) {
let segments = vector.segment_iter().map(|x| x.1);