Refactor the 'Fill' and 'Stroke' nodes to set "fill" and "stroke" attributes for paints (#4257)

* Allow using any graphics type for fill

* Adapt gradient/fill property panels and tools to handle List<T>

* Make the initial gradient transform covers the target's bounding box

* Introduce AnyGraphicListDyn to avoid combinatorial explosion

* Allow using any graphics type for stroke paint

* Add FIll node migration

* Add `for_each_vector_list_mut` instead of `set_paint_attribute`

* Adapt paint flow to read and write attributes instead of legacy Fill/Stroke.color

* Fix responsibilities of paint related node input setters

* Fix Morph by storing List<Graphic> for attributes rather than the concrete types

Store fill/stroke paints as List<Graphic> so Color/Gradient transitions
do not hit set_attribute_value_dyn's type-mismatch fallback to default paint.

* Preserve paint attributes in vector editing ops

* Enhance the clarity between direct and chained fill gradients

* Update demo arts

* Consolidate Fill node gradient appearance inputs

* Fix after the cubic review

* Revert "Consolidate Fill node gradient appearance inputs"

This reverts commit 9622feb20196e2c4da99e98ca95dcc2e34c2c98e.

* Replace AnyGraphicListDyn with generic paint connectors on the Fill and Stroke nodes

* Canonicalize paint attribute storage to List<Graphic> with a single write helper

* Fix Solidify Stroke missing fills stored in the legacy style

* Fix Solidify Stroke producing invisible strokes for legacy-only stroke colors

* Fix the initial gradient transform ignoring the bounding box's vertical extent

* Step paint at the morph midpoint instead of dropping it for unmixable pairings

* Remove migration-stage comments

* Clarify the initial gradient transform helper's doc and name

* Correct the bake_paint_transforms doc and prune dead tolerance arms

* Delete the unused Gradient::lerp

* Fix a comment typo

* Thread network paths through the legacy gradient bake so nested fills migrate

* Restore the legacy fill fallback in Expand Fill and Stroke

* Read gradient stops from the node's own input in the Fill properties panel

* Use the transform input constant instead of a hardcoded index

* Remove the unreachable wired color fallback in the Fill properties solid branch

* Narrow the fill overlay redraw check to actual fill inputs

* Coalesce the fill setter's graph runs into a single dispatch

* Position the Gradient Value node inserted for gradient stops

* Bake backup gradient placement during migration

* Persist pending gradient bakes so unfinished migrations retry on reopen

* Migrate the backup gradient's type and spread method

* Harden the gradient-migration pass against document switches and stale bakes

* Keep the Fill properties UI for layerless and nested Fill nodes

* Leave a wired gradient transform input connected instead of overwriting it

* Refuse to start a gradient chain ahead of existing layer content

* Decode a Fill node's gradient through one shared reader

* Nudge a degenerate bounding box so the Fill gradient transform stays invertible

* Broadcast Fill and Stroke paint with a single attribute-column pass

* Fix Morph stepping the target's stroke in near the source instead of the target

* Tidy conventions: clippy get_first, comment periods, sentence-case test messages

* Reattach the gradient orientation doc to its function

* Re-save demo artwork

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
YohYamasaki
2026-07-05 01:08:21 +02:00
committed by GitHub
parent f82b0a8fca
commit 9f9899cfd0
29 changed files with 1352 additions and 660 deletions

View File

@@ -402,6 +402,7 @@ tagged_value! {
DVec2(DVec2),
#[serde(alias = "Affine2")]
DAffine2(DAffine2),
OptionalDAffine2(Option<DAffine2>),
FillGradient(Gradient),
Font(Font),
Footprint(Footprint),
@@ -583,6 +584,8 @@ impl TaggedValue {
// `Color` (not in a `List`) is still currently needed by `BlackAndWhiteNode` and `ColorOverlayNode` GPU `shader_node(PerPixelAdjust)` variants
() if ty == TypeId::of::<Color>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
() if ty == TypeId::of::<List<Color>>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
// The Fill and Stroke nodes' paint connectors default to `List<Graphic>`, their first registered implementation row
() if ty == TypeId::of::<List<Graphic>>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
() if ty == TypeId::of::<List<GradientStops>>() => to_gradient(string).map(TaggedValue::Gradient)?,
() if ty == TypeId::of::<Fill>() => to_color(string).map(|color| TaggedValue::Fill(Fill::solid(color)))?,
() if ty == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?,

View File

@@ -127,6 +127,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => IVec2]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DVec2]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => DAffine2]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Option<DAffine2>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => bool]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => f64]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => u32]),

View File

@@ -673,6 +673,21 @@ impl ItemAttributeValues {
}
self.0.push((to_key, value));
}
/// Clones the attribute with `key` from `source`, replacing any existing attribute with the same key.
pub fn insert_cloned_from(&mut self, source: &Self, key: &str) {
let Some((_, value)) = source.0.iter().find(|(existing_key, _)| existing_key == key) else {
return;
};
let value = value.clone();
if let Some((_, existing_value)) = self.0.iter_mut().find(|(existing_key, _)| existing_key == key) {
*existing_value = value;
} else {
self.0.push((key.to_string(), value));
}
}
}
// ==========

View File

@@ -1,6 +1,6 @@
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List};
use core_types::ops::{FromAnchorPosition, ListConvert};
use core_types::render_complexity::RenderComplexity;
use core_types::uuid::NodeId;
@@ -215,29 +215,34 @@ pub fn color_to_graphic_list(color: Option<Color>) -> Option<List<Graphic>> {
color.as_ref().map(|color| List::new_from_element((*color).into()))
}
/// Look up the paint graphics stored under attribute for a vector item, normalizing any graphic list type to `List<Graphic>`.
/// Whether a normalized paint graphic list actually carries renderable paint.
/// A 0-item list, or a list whose first graphic is empty, is treated as no paint.
pub fn is_paint_present(graphic_list: &List<Graphic>) -> bool {
graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())
}
/// Look up the paint graphics stored under attribute for a vector item, in the canonical `List<Graphic>` form.
pub fn graphic_list_at<'a>(list: &'a List<Vector>, index: usize, attribute: &str) -> Option<Cow<'a, List<Graphic>>> {
list.attribute::<List<Graphic>>(attribute, index)
.map(Cow::Borrowed)
.or_else(|| list.attribute::<List<Color>>(attribute, index).map(|c| Cow::Owned(c.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<GradientStops>>(attribute, index).map(|g| Cow::Owned(g.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Vector>>(attribute, index).map(|v| Cow::Owned(v.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Raster<CPU>>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list())))
.or_else(|| list.attribute::<List<Raster<GPU>>>(attribute, index).map(|r| Cow::Owned(r.clone().into_graphic_list())))
// Treat a blank attribute as absent so consumers fall back to the legacy `style` instead of masking it.
.filter(|graphic_list| graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty()))
.filter(|graphic_list| is_paint_present(graphic_list))
}
/// Whether the item carries a non-blank paint attribute in any representation (`Graphic`, `Color`,
/// `GradientStops`, `Vector`, or raster), checked by borrowing without cloning the renderable list.
/// Whether the item carries a non-blank canonical `List<Graphic>` paint attribute,
/// checked by borrowing without cloning the renderable list.
pub fn has_paint_at(list: &List<Vector>, index: usize, attribute: &str) -> bool {
list.attribute::<List<Graphic>>(attribute, index)
.is_some_and(|graphics| graphics.element(0).is_some_and(|graphic| !graphic.is_empty()))
|| list.attribute::<List<Color>>(attribute, index).is_some_and(|paint_list| !paint_list.is_empty())
|| list.attribute::<List<GradientStops>>(attribute, index).is_some_and(|paint_list| !paint_list.is_empty())
|| list.attribute::<List<Vector>>(attribute, index).is_some_and(|paint_list| !paint_list.is_empty())
|| list.attribute::<List<Raster<CPU>>>(attribute, index).is_some_and(|paint_list| !paint_list.is_empty())
|| list.attribute::<List<Raster<GPU>>>(attribute, index).is_some_and(|paint_list| !paint_list.is_empty())
list.attribute::<List<Graphic>>(attribute, index).is_some_and(is_paint_present)
}
/// Stores a paint attribute in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute(attributes: &mut ItemAttributeValues, key: &str, paint: impl IntoGraphicList) {
attributes.insert(key, paint.into_graphic_list());
}
/// Stores a paint attribute at a list index in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute_at<T>(list: &mut List<T>, index: usize, key: &str, paint: impl IntoGraphicList) {
list.set_attribute(key, index, paint.into_graphic_list());
}
/// Look up the fill paint graphics for a vector item, falling back to the legacy
@@ -320,6 +325,36 @@ pub fn is_stroke_fully_transparent_at(list: &List<Vector>, index: usize) -> bool
color.a() == 0.
}
/// Bake the provided transform into the per-item transforms of the paint graphics stored under the
/// canonical `List<Graphic>` fill and stroke attributes.
pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) {
fn bake_list_transform<T>(list: &mut List<T>, transform: DAffine2) {
for item_transform in list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*item_transform = transform * *item_transform;
}
}
fn bake_graphic_paint_transform(graphics: &mut List<Graphic>, transform: DAffine2) {
for graphic in graphics.iter_element_values_mut() {
match graphic {
Graphic::Graphic(list) => bake_list_transform(list, transform),
Graphic::Vector(list) => bake_list_transform(list, transform),
Graphic::RasterCPU(list) => bake_list_transform(list, transform),
Graphic::RasterGPU(list) => bake_list_transform(list, transform),
Graphic::Gradient(list) => bake_list_transform(list, transform),
Graphic::Text(list) => bake_list_transform(list, transform),
Graphic::Color(_) => {}
}
}
}
for paint_key in [ATTR_FILL, ATTR_STROKE] {
if let Some(graphics) = attributes.get_mut::<List<Graphic>>(paint_key) {
bake_graphic_paint_transform(graphics, transform);
}
}
}
/// Maps from a concrete element type to its corresponding `Graphic` enum variant,
/// enabling type-directed casting of typed `List`s from a `Graphic` value.
pub trait TryFromGraphic: Clone + Sized {
@@ -357,7 +392,7 @@ impl TryFromGraphic for String {
}
// Local trait to convert types to List<Graphic> (avoids orphan rule issues)
pub trait IntoGraphicList {
pub trait IntoGraphicList: Clone + Send + Sync + Default + std::fmt::Debug + PartialEq + CacheHash + 'static {
fn into_graphic_list(self) -> List<Graphic>;
/// Deeply flattens any content of type `T` within a `List<Graphic>`, discarding all other content, and returning a flat `List<T>`.

View File

@@ -21,7 +21,7 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, has_paint_at, stroke_graphic_list_at};
use graphic_types::graphic::{fill_graphic_list_at, graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute, stroke_graphic_list_at};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster};
use graphic_types::vector_types::gradient::{GradientStops, GradientType};
use graphic_types::vector_types::subpath::Subpath;
@@ -1128,11 +1128,12 @@ impl Render for List<Vector> {
let mut cloned_vector = vector.clone();
cloned_vector.style.clear_stroke();
cloned_vector.style.set_fill(Fill::solid(Color::BLACK));
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
let vector_item = List::new_from_item(Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform));
let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
});
@@ -1197,7 +1198,7 @@ impl Render for List<Vector> {
.style
.stroke()
.map(|stroke| {
if stroke_graphic_list.as_ref().and_then(|l| l.element(0)).is_some() {
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
} else {
String::new()
@@ -1474,11 +1475,12 @@ impl Render for List<Vector> {
if use_layer {
let mut cloned_element = element.clone();
cloned_element.style.clear_stroke();
cloned_element.style.set_fill(Fill::solid(Color::BLACK));
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior.
// The outer opacity/blend layer (above) handles the user-set opacity.
let vector_list = List::new_from_item(Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform));
let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
let vector_list = List::new_from_item(mask_item);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
// This branch is gated on `can_draw_aligned_stroke`, which already requires every subpath is closed

View File

@@ -458,6 +458,15 @@ impl GradientStops {
result
}
pub fn lerp(&self, other: &Self, time: f64) -> Self {
let stops = self.iter().zip(other.iter()).map(|(a, b)| {
let position = a.position + (b.position - a.position) * time;
let color = a.color.lerp(&b.color, time as f32);
GradientStop { position, midpoint: 0.5, color }
});
GradientStops::new(stops)
}
}
#[repr(C)]
@@ -601,30 +610,6 @@ impl Gradient {
}
}
pub fn lerp(&self, other: &Self, time: f64) -> Self {
let start = self.start + (other.start - self.start) * time;
let end = self.end + (other.end - self.end) * time;
let stops = self.stops.iter().zip(other.stops.iter()).map(|(a, b)| {
let position = a.position + (b.position - a.position) * time;
let color = a.color.lerp(&b.color, time as f32);
GradientStop { position, midpoint: 0.5, color }
});
let stops = GradientStops::new(stops);
let gradient_type = if time < 0.5 { self.gradient_type } else { other.gradient_type };
let spread_method = if time < 0.5 { self.spread_method } else { other.spread_method };
Self {
start,
end,
stops,
gradient_type,
spread_method,
// TODO: Eventually remove this document upgrade code
absolute: self.absolute,
transform: if time < 0.5 { self.transform } else { other.transform },
}
}
/// Insert a stop into the gradient, the index if successful
pub fn insert_stop(&mut self, mouse: DVec2, transform: DAffine2) -> Option<usize> {
// Transform the start and end positions to the same coordinate space as the mouse.
@@ -663,6 +648,54 @@ impl Gradient {
}
}
/// Rebuild the y-axis so its (parallel, perpendicular) components in the x-axis-aligned frame stay constant, both
/// rescaled by `|new_x| / |old_x|`. This holds the (x, y) parallelogram's aspect ratio and skew fixed across an endpoint
/// drag, so a radial ellipse stays the same shape (just rotated and resized) instead of distorting as x grows or shrinks.
/// Falls back to a +90° rotation of `new_x` when `old_x` is degenerate.
fn scale_y_axis_to_match_new_x(old_x: DVec2, old_y: DVec2, new_x: DVec2) -> DVec2 {
let old_x_length = old_x.length();
if old_x_length < 1e-9 {
return DVec2::new(-new_x.y, new_x.x);
}
let ex_old = old_x / old_x_length;
let ey_old = DVec2::new(-ex_old.y, ex_old.x);
let new_x_length = new_x.length();
if new_x_length < 1e-9 {
return DVec2::ZERO;
}
let ex_new = new_x / new_x_length;
let ey_new = DVec2::new(-ex_new.y, ex_new.x);
let parallel = old_y.dot(ex_old);
let perpendicular = old_y.dot(ey_old);
let scale = new_x_length / old_x_length;
scale * (parallel * ex_new + perpendicular * ey_new)
}
/// Build a new affine that maps canonical (0,0) -> (1,0) to (new_start, new_end), preserving the y-axis
/// shape of `old` proportionally to the x-axis length change.
pub fn build_transform_with_y_preservation(old: DAffine2, new_start: DVec2, new_end: DVec2) -> DAffine2 {
let new_x_axis = new_end - new_start;
let preserved_y_axis = scale_y_axis_to_match_new_x(old.matrix2.x_axis, old.matrix2.y_axis, new_x_axis);
DAffine2 {
matrix2: glam::DMat2::from_cols(new_x_axis, preserved_y_axis),
translation: new_start,
}
}
/// Build the default transform for a gradient not yet given one: a horizontal gradient spanning the
/// bounding box's width, running through its vertical middle.
pub fn initial_gradient_transform_for_bounding_box(bounds: [DVec2; 2]) -> DAffine2 {
let [min, max] = bounds;
let x_axis = DVec2::new(max.x - min.x, 0.);
DAffine2 {
matrix2: glam::DMat2::from_cols(x_axis, x_axis.perp()),
translation: DVec2::new(min.x, (min.y + max.y) / 2.),
}
}
// TODO: Eventually remove this migration document upgrade code
pub fn migrate_to_gradient_stops<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<GradientStops, D::Error> {
use serde::Deserialize;

View File

@@ -66,30 +66,6 @@ impl Fill {
}
}
pub fn lerp(&self, other: &Self, time: f64) -> Self {
let transparent = Self::solid(Color::TRANSPARENT);
let a = if *self == Self::None && *other != Self::None { &transparent } else { self };
let b = if *other == Self::None && *self != Self::None { &transparent } else { other };
match (a, b) {
(Self::Solid(a), Self::Solid(b)) => Self::Solid(a.lerp(b, time as f32)),
(Self::Solid(a), Self::Gradient(b)) => {
let mut solid_to_gradient = b.clone();
solid_to_gradient.stops.color.iter_mut().for_each(|color| *color = *a);
let a = &solid_to_gradient;
Self::Gradient(a.lerp(b, time))
}
(Self::Gradient(a), Self::Solid(b)) => {
let mut gradient_to_solid = a.clone();
gradient_to_solid.stops.color.iter_mut().for_each(|color| *color = *b);
let b = &gradient_to_solid;
Self::Gradient(a.lerp(b, time))
}
(Self::Gradient(a), Self::Gradient(b)) => Self::Gradient(a.lerp(b, time)),
(Self::None, _) | (_, Self::None) => Self::None,
}
}
/// Extract a gradient from the fill
pub fn as_gradient(&self) -> Option<&Gradient> {
match self {
@@ -389,7 +365,8 @@ fn daffine2_identity() -> DAffine2 {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(default))]
pub struct Stroke {
/// Stroke color
/// Deprecated, use `ATTR_STROKE` instead.
/// TODO: Remove once all stroke paint sources flow through `List<Graphic>` directly without going through `Stroke.color`.
pub color: Option<Color>,
/// Line thickness
pub weight: f64,
@@ -632,30 +609,6 @@ impl PathStyle {
Self { stroke, fill }
}
pub fn lerp(&self, other: &Self, time: f64) -> Self {
Self {
fill: self.fill.lerp(&other.fill, time),
stroke: match (self.stroke.as_ref(), other.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 {
Some(b.clone())
} else {
None
}
}
(None, None) => None,
},
}
}
/// Get the current path's [Fill].
///
/// # Example
@@ -690,25 +643,6 @@ impl PathStyle {
self.stroke.clone()
}
/// Replace the path's [Fill] with a provided one.
///
/// # Example
/// ```
/// # use vector_types::vector::style::{Fill, PathStyle};
/// # use core_types::Color;
/// let mut style = PathStyle::default();
///
/// assert_eq!(*style.fill(), Fill::None);
///
/// let fill = Fill::solid(Color::RED);
/// style.set_fill(fill.clone());
///
/// assert_eq!(*style.fill(), fill);
/// ```
pub fn set_fill(&mut self, fill: Fill) {
self.fill = fill;
}
pub fn set_stroke_transform(&mut self, transform: DAffine2) {
if let Some(stroke) = &mut self.stroke {
stroke.transform = transform;

View File

@@ -9,7 +9,7 @@ use vector_types::GradientStops;
/// Constructs a single-element `Artboard[]` with the given content and metadata stored as row attributes.
#[node_macro::node(category(""))]
pub async fn create_artboard<T: IntoGraphicList + 'n>(
pub async fn create_artboard<T: IntoGraphicList>(
ctx: impl ExtractAll + CloneVarArgs + Ctx,
/// Graphics to include within the artboard.
#[implementations(

View File

@@ -565,7 +565,7 @@ pub async fn wrap_graphic<T: Into<Graphic> + 'n>(
/// Converts a list of graphical content into a `Graphic[]` by placing it into an element of a new wrapper `Graphic[]`.
/// If it is already a `Graphic[]`, it is not wrapped again. Use the 'Wrap Graphic' node if wrapping is always desired.
#[node_macro::node(category("General"))]
pub async fn to_graphic<T: IntoGraphicList + 'n>(
pub async fn to_graphic<T: IntoGraphicList>(
_: impl Ctx,
#[implementations(
List<Graphic>,
@@ -620,7 +620,7 @@ pub async fn flatten_graphic(_: impl Ctx, content: List<Graphic>, fully_flatten:
/// Converts a `Graphic[]` into a `Vector[]` by deeply flattening any vector content it contains, and discarding any non-vector content.
#[node_macro::node(category("Vector"))]
pub async fn flatten_vector<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
pub async fn flatten_vector<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
let graphic_list = content.into_graphic_list();
let mut output: List<Vector> = graphic_list.clone().into_flattened_list();
@@ -653,25 +653,25 @@ pub async fn flatten_vector<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx,
/// Converts a `Graphic[]` into a `Raster[]` by deeply flattening any raster content it contains, and discarding any non-raster content.
#[node_macro::node(category("Raster"))]
pub async fn flatten_raster<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Raster<CPU>>)] content: T) -> List<Raster<CPU>> {
pub async fn flatten_raster<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Raster<CPU>>)] content: T) -> List<Raster<CPU>> {
content.into_flattened_list()
}
/// Converts a `Graphic[]` into a `Color[]` by deeply flattening any color content it contains, and discarding any non-color content.
#[node_macro::node(category("General"))]
pub async fn flatten_color<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] content: T) -> List<Color> {
pub async fn flatten_color<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] content: T) -> List<Color> {
content.into_flattened_list()
}
/// Converts a `Graphic[]` into a `GradientStops[]` by deeply flattening any gradient content it contains, and discarding any non-gradient content.
#[node_macro::node(category("General"))]
pub async fn flatten_gradient<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<GradientStops>)] content: T) -> List<GradientStops> {
pub async fn flatten_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<GradientStops>)] content: T) -> List<GradientStops> {
content.into_flattened_list()
}
/// Constructs a gradient from a `Color[]`, where the colors are evenly distributed as gradient stops across the range from 0 to 1.
#[node_macro::node(category("Color"))]
fn colors_to_gradient<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> List<GradientStops> {
fn colors_to_gradient<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Color>)] colors: T) -> List<GradientStops> {
let colors = colors.into_flattened_list::<Color>();
let total_colors = colors.len();

View File

@@ -1,21 +1,22 @@
use core_types::list::{Item, List};
use core_types::list::{ATTR_FILL, Item, List};
use core_types::uuid::NodeId;
use core_types::{
ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, BlendMode, Color,
Ctx,
};
use glam::{DAffine2, DVec2};
use graphic_types::vector_types::gradient::{Gradient, GradientSpreadMethod, GradientType};
use graphic_types::graphic::{bake_paint_transforms, set_paint_attribute};
use graphic_types::vector_types::gradient::{GradientSpreadMethod, GradientType};
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
use graphic_types::vector_types::vector::PointId;
use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
use graphic_types::vector_types::vector::style::Fill;
use graphic_types::{Graphic, Vector};
use linesweeper::topology::Topology;
use linesweeper::{BinaryOp, FillRule, binary_op};
use smallvec::SmallVec;
use vector_types::kurbo::{Affine, BezPath, CubicBez, Line, ParamCurve, PathSeg, Point, QuadBez};
pub use vector_types::vector::misc::BooleanOperation;
use vector_types::vector::style::Fill;
// TODO: Fix boolean ops to work by removing .transform() and .one_instance_*() calls,
// TODO: since before we used a Vec of single-item `List`s and now we use a single `List`
@@ -23,7 +24,7 @@ pub use vector_types::vector::misc::BooleanOperation;
/// Combines the geometric forms of one or more closed paths into a new vector path that results from cutting or joining the paths by the chosen method.
#[node_macro::node(category("Vector: Modifier"), memoize)]
async fn boolean_operation<I: graphic_types::IntoGraphicList + 'n + Send + Clone>(
async fn boolean_operation<I: graphic_types::IntoGraphicList>(
_: impl Ctx,
/// The `List` of vector paths to perform the boolean operation on. Nested `List`s are automatically flattened.
#[implementations(List<Graphic>, List<Vector>)]
@@ -143,12 +144,15 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
let copy_from_transform: DAffine2 = vector.attribute_cloned_or_default(ATTR_TRANSFORM, index);
// The boolean op bakes input transforms into the output geometry, so the result item carries no transform of its own
attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY);
bake_paint_transforms(&mut attributes, copy_from_transform);
let copy_from = vector.element(index).unwrap();
let mut element = Vector {
style: copy_from.style.clone(),
..Default::default()
};
// An absolute gradient lives in the geometry's space, so bake the same transform into it to track the baked points
// Legacy Fill fallback: An absolute gradient lives in the geometry's space, so bake the same transform into it to track the baked points
if let Fill::Gradient(gradient) = element.style.fill_mut()
&& gradient.absolute
{
@@ -204,15 +208,16 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
let mut element = Vector::from_subpath(subpath);
element.style.set_fill(Fill::Solid(Color::BLACK));
let element = Vector::from_subpath(subpath);
Item::new_from_element(element)
let mut item = Item::new_from_element(element)
.with_attribute(ATTR_BLEND_MODE, blend_mode)
.with_attribute(ATTR_OPACITY, opacity)
.with_attribute(ATTR_OPACITY_FILL, fill)
.with_attribute(ATTR_CLIPPING_MASK, clip)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer);
set_paint_attribute(item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
item
};
// Apply the parent graphic's transform to each raster element, preserving each item's layer
@@ -236,15 +241,16 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
subpath.apply_transform(transform);
let mut element = Vector::from_subpath(subpath);
element.style.set_fill(Fill::Solid(Color::BLACK));
let element = Vector::from_subpath(subpath);
Item::new_from_element(element)
let mut item = Item::new_from_element(element)
.with_attribute(ATTR_BLEND_MODE, blend_mode)
.with_attribute(ATTR_OPACITY, opacity)
.with_attribute(ATTR_OPACITY_FILL, fill)
.with_attribute(ATTR_CLIPPING_MASK, clip)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer);
set_paint_attribute(item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK));
item
};
// Apply the parent graphic's transform to each raster element, preserving each item's layer
@@ -278,9 +284,10 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
Graphic::Color(color) => color
.into_iter()
.map(|row| {
let (color, attributes) = row.into_parts();
let (color, mut attributes) = row.into_parts();
set_paint_attribute(&mut attributes, ATTR_FILL, List::new_from_element(color));
let mut element = Vector::default();
element.style.set_fill(Fill::Solid(color));
element.style.set_stroke_transform(DAffine2::IDENTITY);
Item::from_parts(element, attributes)
@@ -289,19 +296,21 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
Graphic::Gradient(gradient) => gradient
.into_iter()
.map(|row| {
let (stops, attributes) = row.into_parts();
let (stops, mut attributes) = row.into_parts();
let mut gradient_paint = List::new_from_element(stops);
if let Some(transform) = attributes.remove::<DAffine2>(ATTR_TRANSFORM) {
gradient_paint.set_attribute(ATTR_TRANSFORM, 0, transform);
}
if let Some(gradient_type) = attributes.remove::<GradientType>(ATTR_GRADIENT_TYPE) {
gradient_paint.set_attribute(ATTR_GRADIENT_TYPE, 0, gradient_type);
}
if let Some(spread_method) = attributes.remove::<GradientSpreadMethod>(ATTR_SPREAD_METHOD) {
gradient_paint.set_attribute(ATTR_SPREAD_METHOD, 0, spread_method);
}
set_paint_attribute(&mut attributes, ATTR_FILL, gradient_paint);
let mut element = Vector::default();
// Convert the gradient's transform to absolute endpoints, matching `From<List<GradientStops>> for Fill`
let transform = attributes.get::<DAffine2>(ATTR_TRANSFORM).cloned().unwrap_or_default();
element.style.set_fill(Fill::Gradient(Gradient {
stops,
gradient_type: attributes.get::<GradientType>(ATTR_GRADIENT_TYPE).cloned().unwrap_or_default(),
spread_method: attributes.get::<GradientSpreadMethod>(ATTR_SPREAD_METHOD).cloned().unwrap_or_default(),
start: transform.transform_point2(DVec2::ZERO),
end: transform.transform_point2(DVec2::X),
absolute: true,
transform: DAffine2::IDENTITY,
}));
element.style.set_stroke_transform(DAffine2::IDENTITY);
Item::from_parts(element, attributes)

View File

@@ -3,22 +3,24 @@ 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, List, ListDyn};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn};
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
use core_types::transform::{Footprint, Transform};
use core_types::uuid::NodeId;
use core_types::{
ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CloneVarArgs, Color, Context, Ctx, ExtractAll,
OwnedContextImpl,
ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_GRADIENT_TYPE, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, CloneVarArgs,
Color, Context, Ctx, ExtractAll, OwnedContextImpl,
};
use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Vector;
use graphic_types::graphic::{bake_paint_transforms, fill_graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute, set_paint_attribute_at, stroke_graphic_list_at};
use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{Graphic, IntoGraphicList};
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
use rand::{Rng, SeedableRng};
use std::collections::hash_map::DefaultHasher;
use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box};
use vector_types::subpath::{BezierHandles, ManipulatorGroup};
use vector_types::vector::PointDomain;
use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath};
@@ -29,14 +31,17 @@ 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::{Fill, Gradient, GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::{GradientSpreadMethod, GradientType};
/// Implemented for types that contain vector items reachable via mutable access.
/// Used for the fill and stroke nodes so they can apply to either `List<Graphic>` or `List<Vector>`.
trait VectorListIterMut {
fn for_each_vector_mut(&mut self, f: impl FnMut(&mut Vector, DAffine2));
fn for_each_vector_list_mut(&mut self, f: impl FnMut(&mut List<Vector>));
fn vector_count(&self) -> usize;
}
@@ -51,6 +56,14 @@ impl VectorListIterMut for List<Graphic> {
}
}
fn for_each_vector_list_mut(&mut self, mut f: impl FnMut(&mut List<Vector>)) {
for graphic in self.iter_element_values_mut() {
if let Some(vector_list) = graphic.as_vector_mut() {
f(vector_list);
};
}
}
fn vector_count(&self) -> usize {
self.iter_element_values().filter_map(|element| element.as_vector()).map(|list| list.len()).sum()
}
@@ -64,6 +77,10 @@ impl VectorListIterMut for List<Vector> {
}
}
fn for_each_vector_list_mut(&mut self, mut f: impl FnMut(&mut List<Vector>)) {
f(self);
}
fn vector_count(&self) -> usize {
self.len()
}
@@ -109,26 +126,29 @@ where
let mut rng = rand::rngs::StdRng::seed_from_u64(seed.into());
let mut i: usize = 0;
content.for_each_vector_mut(|vector, _transform| {
let factor = match randomize {
true => rng.random::<f64>(),
false => match repeat_every {
0 => i as f64 / (length - 1).max(1) as f64,
1 => 0.,
_ => i as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
},
};
content.for_each_vector_list_mut(|vector_list| {
for index in 0..vector_list.len() {
let factor = match randomize {
true => rng.random::<f64>(),
false => match repeat_every {
0 => i as f64 / (length - 1).max(1) as f64,
1 => 0.,
_ => i as f64 % repeat_every as f64 / (repeat_every - 1) as f64,
},
};
let color = gradient.evaluate(factor);
let color = gradient.evaluate(factor);
let paint = List::new_from_element(color).into_graphic_list();
if fill {
vector.style.set_fill(Fill::Solid(color));
if fill {
set_paint_attribute_at(vector_list, index, ATTR_FILL, paint.clone());
}
if stroke && vector_list.element(index).and_then(|vector| vector.style.stroke()).is_some() {
set_paint_attribute_at(vector_list, index, ATTR_STROKE, paint.clone());
}
i += 1;
}
if stroke && let Some(stroke) = vector.style.stroke().and_then(|stroke| stroke.with_color(&Some(color))) {
vector.style.set_stroke(stroke);
}
i += 1;
});
content
@@ -136,41 +156,77 @@ where
/// Applies a fill style to the vector content, giving an appearance to the area within the interior of the geometry.
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("fill_properties"))]
async fn fill<F: Into<Fill> + 'n + Send, V: VectorListIterMut + 'n + Send>(
async fn fill<V: VectorListIterMut + 'n + Send, F: IntoGraphicList + 'n + Send + 'static>(
_: impl Ctx,
/// The content with vector paths to apply the fill style to.
#[implementations(
List<Vector>,
List<Vector>,
List<Vector>,
List<Vector>,
List<Graphic>,
List<Graphic>,
List<Graphic>,
List<Graphic>,
List<Vector>, List<Vector>, List<Vector>, List<Vector>, List<Vector>, List<Vector>,
List<Graphic>, List<Graphic>, List<Graphic>, List<Graphic>, List<Graphic>, List<Graphic>,
)]
mut content: V,
/// The fill to paint the path with.
#[default(Color::BLACK)]
#[implementations(
Fill,
List<Color>,
List<GradientStops>,
Gradient,
Fill,
List<Color>,
List<GradientStops>,
Gradient,
List<Graphic>, List<Vector>, List<Color>, List<GradientStops>, List<Raster<CPU>>, List<Raster<GPU>>,
List<Graphic>, List<Vector>, List<Color>, List<GradientStops>, List<Raster<CPU>>, List<Raster<GPU>>,
)]
fill: F,
mut fill: F,
_backup_color: List<Color>,
_backup_gradient: Gradient,
_backup_gradient: List<GradientStops>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_transform: Option<DAffine2>,
) -> V {
let fill: Fill = fill.into();
content.for_each_vector_mut(|vector, _transform| {
vector.style.set_fill(fill.clone());
});
if let Some(gradient) = (&mut fill as &mut dyn std::any::Any).downcast_mut::<List<GradientStops>>() {
if gradient.iter_attribute_values::<GradientType>(ATTR_GRADIENT_TYPE).is_none() {
for value in gradient.iter_attribute_values_mut_or_default::<GradientType>(ATTR_GRADIENT_TYPE) {
*value = _gradient_type;
}
}
if gradient.iter_attribute_values::<GradientSpreadMethod>(ATTR_SPREAD_METHOD).is_none() {
for value in gradient.iter_attribute_values_mut_or_default::<GradientSpreadMethod>(ATTR_SPREAD_METHOD) {
*value = _spread_method;
}
}
if gradient.iter_attribute_values::<DAffine2>(ATTR_TRANSFORM).is_none() {
let transform = _transform.unwrap_or_else(|| {
// Construct a transform that covers the bounding box of the paint target
let mut bounds: Option<[DVec2; 2]> = None;
content.for_each_vector_mut(|vector, _| {
if let Some([min, max]) = vector.bounding_box() {
bounds = Some(match bounds {
Some([bmin, bmax]) => [bmin.min(min), bmax.max(max)],
None => [min, max],
});
}
});
// Nudge a degenerate axis so the gradient transform stays invertible, matching the editor's `nonzero_bounding_box`
let [min, mut max] = bounds.unwrap_or([DVec2::ZERO, DVec2::ONE]);
if max.x - min.x < 1e-10 {
max.x = min.x + 1.;
}
if max.y - min.y < 1e-10 {
max.y = min.y + 1.;
}
initial_gradient_transform_for_bounding_box([min, max])
});
for value in gradient.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*value = transform;
}
}
}
let fill = fill.into_graphic_list();
content.for_each_vector_list_mut(|vector_list| {
// Broadcast the same paint to every item, scanning the attribute column once instead of per index
for slot in vector_list.iter_attribute_values_mut_or_default::<List<Graphic>>(ATTR_FILL) {
*slot = fill.clone();
}
});
content
}
@@ -195,14 +251,41 @@ impl IntoF64Vec for String {
/// Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.
#[node_macro::node(category("Vector: Style"), path(graphene_core::vector), properties("stroke_properties"))]
async fn stroke<V, L: IntoF64Vec>(
async fn stroke<V, L: IntoF64Vec, P: IntoGraphicList + 'n + Send + 'static>(
_: impl Ctx,
/// The content with vector paths to apply the stroke style to.
#[implementations(List<Vector>, List<Vector>, List<Vector>, List<Graphic>, List<Graphic>, List<Graphic>)]
#[implementations(
List<Vector>, List<Vector>, List<Vector>,
List<Vector>, List<Vector>, List<Vector>,
List<Vector>, List<Vector>, List<Vector>,
List<Vector>, List<Vector>, List<Vector>,
List<Vector>, List<Vector>, List<Vector>,
List<Vector>, List<Vector>, List<Vector>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Graphic>, List<Graphic>, List<Graphic>,
)]
mut content: List<V>,
/// The stroke color.
/// The stroke paint.
#[default(Color::BLACK)]
color: List<Color>,
#[implementations(
List<Graphic>, List<Graphic>, List<Graphic>,
List<Vector>, List<Vector>, List<Vector>,
List<Color>, List<Color>, List<Color>,
List<GradientStops>, List<GradientStops>, List<GradientStops>,
List<Raster<CPU>>, List<Raster<CPU>>, List<Raster<CPU>>,
List<Raster<GPU>>, List<Raster<GPU>>, List<Raster<GPU>>,
List<Graphic>, List<Graphic>, List<Graphic>,
List<Vector>, List<Vector>, List<Vector>,
List<Color>, List<Color>, List<Color>,
List<GradientStops>, List<GradientStops>, List<GradientStops>,
List<Raster<CPU>>, List<Raster<CPU>>, List<Raster<CPU>>,
List<Raster<GPU>>, List<Raster<GPU>>, List<Raster<GPU>>,
)]
paint: P,
/// The stroke thickness.
#[unit(" px")]
#[default(2.)]
@@ -220,7 +303,20 @@ async fn stroke<V, L: IntoF64Vec>(
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
paint_order: PaintOrder,
/// The stroke dash lengths. 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.
#[implementations(List<f64>, f64, String, List<f64>, f64, String)]
#[implementations(
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
List<f64>, f64, String,
)]
dash_lengths: L,
/// The phase offset distance from the starting point of the dash pattern.
#[unit(" px")]
@@ -232,7 +328,8 @@ where
let dash_lengths = dash_lengths.into_vec().into_iter().map(|length| length.max(0.)).collect();
let stroke = Stroke {
color: color.element(0).copied(),
// TODO: Remove once the deprecated `Stroke.color` field is deleted in favor of the `ATTR_STROKE` attribute
color: None,
weight,
dash_lengths,
dash_offset,
@@ -250,6 +347,13 @@ where
vector.style.set_stroke(stroke);
});
let paint = paint.into_graphic_list();
content.for_each_vector_list_mut(|vector_list| {
// Broadcast the same paint to every item, scanning the attribute column once instead of per index
for slot in vector_list.iter_attribute_values_mut_or_default::<List<Graphic>>(ATTR_STROKE) {
*slot = paint.clone();
}
});
content
}
@@ -1156,15 +1260,21 @@ async fn offset_path(_: impl Ctx, content: List<Vector>, distance: f64, join: St
}
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
async fn solidify_stroke<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
async fn solidify_stroke<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
// TODO: Make this node support stroke align, which it currently ignores
let graphic_list = content.into_graphic_list();
let flattened: List<Vector> = graphic_list.clone().into_flattened_list();
// A fill exists when the canonical attribute carries paint or, matching the renderer's fallback, when the legacy `style.fill` does
let has_fills: Vec<bool> = (0..flattened.len())
.map(|index| has_paint_at(&flattened, index, ATTR_FILL) || flattened.element(index).is_some_and(|vector| !vector.style.fill().is_none()))
.collect();
let mut output: List<Vector> = flattened
.into_iter()
.flat_map(|row| {
.zip(has_fills)
.flat_map(|(row, has_fill)| {
let (mut vector, attributes) = row.into_parts();
let stroke = vector.style.stroke().clone().unwrap_or_default();
@@ -1197,7 +1307,7 @@ async fn solidify_stroke<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[
let stroke_options_default = kurbo::StrokeOpts::default();
let stroke_options_stable = kurbo::StrokeOpts::default().stable_dash_order(true);
// 0.25 is balanced between performace and accuracy of the curve.
// 0.25 is balanced between performance and accuracy of the curve.
const STROKE_TOLERANCE: f64 = 0.25;
for mut path in bezpaths {
@@ -1214,14 +1324,7 @@ async fn solidify_stroke<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[
solidified_stroke.append_bezpath(solidified);
}
// We set the solidified stroke's fill to the stroke's color and without a stroke.
if let Some(stroke) = vector.style.stroke() {
solidified_stroke.style.set_fill(Fill::solid_or_none(stroke.color));
}
// If the original vector has a fill, preserve it as a separate item with the stroke cleared.
let has_attr_fill = attributes.keys().any(|k| k == ATTR_FILL);
let has_fill = has_attr_fill || !vector.style.fill().is_none();
let fill_row = has_fill.then(|| {
vector.style.clear_stroke();
let mut fill_attributes = attributes.clone();
@@ -1235,6 +1338,13 @@ async fn solidify_stroke<T: IntoGraphicList + 'n + Send + Clone>(_: impl Ctx, #[
stroke_attributes.remove::<List<Graphic>>(ATTR_FILL);
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
// Fall back to the legacy stroke color when no canonical stroke paint attribute was carried over
if !stroke_attributes.get::<List<Graphic>>(ATTR_FILL).is_some_and(is_paint_present)
&& let Some(color) = stroke.color
{
set_paint_attribute(&mut stroke_attributes, ATTR_FILL, List::new_from_element(color));
}
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.
@@ -1331,12 +1441,14 @@ async fn map_points(ctx: impl Ctx + CloneVarArgs + ExtractAll, content: List<Vec
// TODO: Rename to "Combine Paths" and make this happen per-element instead of flattening every element into a single path. The migration for this should then become a Flatten Vector -> Combine Paths pair of nodes.
#[node_macro::node(category("Vector"), path(graphene_core::vector))]
pub async fn flatten_path<T: IntoGraphicList + 'n + Send>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
pub async fn flatten_path<T: IntoGraphicList>(_: impl Ctx, #[implementations(List<Graphic>, List<Vector>)] content: T) -> List<Vector> {
let graphic_list = content.into_graphic_list();
let flattened = graphic_list.clone().into_flattened_list::<Vector>();
// Create a `List` with one empty `Vector` element, then get a mutable reference to it which we append flattened subpaths to
let mut output_list = List::new_from_element(Vector::default());
let mut primary_source = None;
let output = output_list.element_mut(0).unwrap();
// Concatenate every vector element's subpaths into the single output compound path
@@ -1349,11 +1461,30 @@ pub async fn flatten_path<T: IntoGraphicList + 'n + Send>(_: impl Ctx, #[impleme
(index, node_id).hash(&mut hasher);
let collision_hash_seed = hasher.finish();
output.concat(element, flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index), collision_hash_seed);
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 style
// Use the last encountered style as the output style
output.style = element.style.clone();
primary_source = Some((index, source_transform));
}
if let Some((primary, source_transform)) = primary_source {
let source_attributes = flattened.clone_item_attributes(primary);
let mut attributes = ItemAttributeValues::new();
attributes.insert_cloned_from(&source_attributes, ATTR_FILL);
attributes.insert_cloned_from(&source_attributes, ATTR_STROKE);
bake_paint_transforms(&mut attributes, source_transform);
let output = std::mem::take(output_list.element_mut(0).unwrap());
output_list = List::new_from_item(Item::from_parts(output, attributes));
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
let layer_path: List<NodeId> = flattened.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, primary);
output_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
// Preserve a reference to the original upstream `List<Graphic>` so the renderer can recurse into it
@@ -1361,13 +1492,6 @@ pub async fn flatten_path<T: IntoGraphicList + 'n + Send>(_: impl Ctx, #[impleme
// This is the same mechanism Boolean Operation uses to keep its inputs editable after the merge.
output_list.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, graphic_list);
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
if !flattened.is_empty() {
let primary = flattened.len() - 1;
let layer_path: List<NodeId> = flattened.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, primary);
output_list.set_attribute(ATTR_EDITOR_LAYER_PATH, 0, layer_path);
}
output_list
}
@@ -2064,7 +2188,7 @@ async fn offset_points(
///
/// *Progression* morphs through all objects. Interpolation is linear unless *Path* geometry is provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments.
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
async fn morph<I: IntoGraphicList + 'n + Send + Clone>(
async fn morph<I: IntoGraphicList>(
_: impl Ctx,
/// The vector objects to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements.
#[implementations(List<Graphic>, List<Vector>)]
@@ -2177,6 +2301,79 @@ async fn morph<I: IntoGraphicList + 'n + Send + Clone>(
}
}
fn lerp_gradient_transform(gradient_list_a: &List<GradientStops>, gradient_list_b: &List<GradientStops>, time: f64) -> DAffine2 {
let transform_a = gradient_list_a.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0);
let transform_b = gradient_list_b.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0);
let start_a = transform_a.translation;
let end_a = transform_a.translation + transform_a.matrix2.x_axis;
let start_b = transform_b.translation;
let end_b = transform_b.translation + transform_b.matrix2.x_axis;
let start = start_a.lerp(start_b, time);
let end = end_a.lerp(end_b, time);
let metadata_source_transform = if time < 0.5 { transform_a } else { transform_b };
build_transform_with_y_preservation(metadata_source_transform, start, end)
}
// Lerp between two graphics. Solid color and gradient pairings interpolate; all other pairings step at the midpoint.
fn lerp_graphic(a: Option<&List<Graphic>>, b: Option<&List<Graphic>>, time: f64) -> Option<List<Graphic>> {
let transparent = List::new_from_element(Color::TRANSPARENT).into_graphic_list();
let a = a.filter(|graphic_list| is_paint_present(graphic_list));
let b = b.filter(|graphic_list| is_paint_present(graphic_list));
let (a, b) = match (a, b) {
(None, None) => return None,
(Some(a), None) => (a, &transparent),
(None, Some(b)) => (&transparent, b),
(Some(a), Some(b)) => (a, b),
};
// This keeps the gradient metadata attributes
let gradient_with_stops = |mut gradient_list: List<GradientStops>, stops: GradientStops| -> Graphic {
if let Some(target) = gradient_list.element_mut(0) {
*target = stops;
} else {
gradient_list.push(Item::new_from_element(stops));
}
Graphic::Gradient(gradient_list)
};
let graphic = match (a.element(0), b.element(0)) {
(Some(Graphic::Color(color_list_a)), Some(Graphic::Color(color_list_b))) => color_list_a
.element(0)
.zip(color_list_b.element(0))
.map(|(color_a, color_b)| Graphic::from(color_a.lerp(color_b, time as f32))),
(Some(Graphic::Color(color_list_a)), Some(Graphic::Gradient(gradient_list_b))) => color_list_a.element(0).zip(gradient_list_b.element(0)).map(|(color_a, stops_b)| {
let mut solid_to_gradient = stops_b.clone();
solid_to_gradient.color.iter_mut().for_each(|color| *color = *color_a);
let stops = solid_to_gradient.lerp(stops_b, time);
gradient_with_stops(gradient_list_b.clone(), stops)
}),
(Some(Graphic::Gradient(gradient_list_a)), Some(Graphic::Color(color_list_b))) => gradient_list_a.element(0).zip(color_list_b.element(0)).map(|(stops_a, color_b)| {
let mut gradient_to_solid = stops_a.clone();
gradient_to_solid.color.iter_mut().for_each(|color| *color = *color_b);
let stops = stops_a.lerp(&gradient_to_solid, time);
gradient_with_stops(gradient_list_a.clone(), stops)
}),
(Some(Graphic::Gradient(gradient_list_a)), Some(Graphic::Gradient(gradient_list_b))) => gradient_list_a.element(0).zip(gradient_list_b.element(0)).map(|(stops_a, stops_b)| {
let stops = stops_a.lerp(stops_b, time);
let metadata_source = if time < 0.5 { gradient_list_a } else { gradient_list_b };
let mut gradient_list = metadata_source.clone();
gradient_list.set_attribute(ATTR_TRANSFORM, 0, lerp_gradient_transform(gradient_list_a, gradient_list_b, time));
gradient_with_stops(gradient_list, stops)
}),
// Pairings beyond solid colors and gradients (raster, vector, or mixed) can't be interpolated, so step at the midpoint
_ => return Some(if time < 0.5 { a.clone() } else { b.clone() }),
};
graphic.map(List::new_from_element)
}
// Preserve original `List<Graphic>` as upstream data so this group layer's nested layers can be edited by the tools.
let mut graphic_list_content = content.clone().into_graphic_list();
@@ -2435,9 +2632,35 @@ async fn morph<I: IntoGraphicList + 'n + Send + Clone>(
return List::new_from_item(Item::from_parts(endpoint_element.clone(), attributes));
}
let mut vector = Vector {
style: source_element.style.lerp(&target_element.style, time),
..Default::default()
let mut vector = Vector::default();
vector.style.stroke = match (source_element.style.stroke.as_ref(), target_element.style.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 fill_paint = {
let source = fill_graphic_list_at(&content, source_index);
let target = fill_graphic_list_at(&content, target_index);
lerp_graphic(source.as_deref(), target.as_deref(), time)
};
let stroke_paint = {
let source = stroke_graphic_list_at(&content, source_index);
let target = stroke_graphic_list_at(&content, target_index);
lerp_graphic(source.as_deref(), target.as_deref(), time)
};
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
@@ -2588,16 +2811,23 @@ async fn morph<I: IntoGraphicList + 'n + Send + Clone>(
let primary_index = if time < 0.5 { source_index } else { target_index };
let layer_path: List<NodeId> = content.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, primary_index);
List::new_from_item(
Item::new_from_element(vector)
.with_attribute(ATTR_TRANSFORM, lerped_transform)
.with_attribute(ATTR_BLEND_MODE, lerped_blend_mode)
.with_attribute(ATTR_OPACITY, lerped_opacity)
.with_attribute(ATTR_OPACITY_FILL, lerped_fill)
.with_attribute(ATTR_CLIPPING_MASK, lerped_clip)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
.with_attribute(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content),
)
let mut item = Item::new_from_element(vector)
.with_attribute(ATTR_TRANSFORM, lerped_transform)
.with_attribute(ATTR_BLEND_MODE, lerped_blend_mode)
.with_attribute(ATTR_OPACITY, lerped_opacity)
.with_attribute(ATTR_OPACITY_FILL, lerped_fill)
.with_attribute(ATTR_CLIPPING_MASK, lerped_clip)
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
.with_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);
}
List::new_from_item(item)
}
fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Vector {
@@ -3207,6 +3437,36 @@ mod test {
assert!((morphed.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0).translation - DVec2::new(-50., -50.)).length() < 1e-3);
}
#[tokio::test]
async fn morph_interpolates_fill() {
let rect = || {
let mut v = Vector::default();
v.append_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY));
v
};
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());
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());
let mut content = List::new_from_item(item_a);
content.push(item_b);
let morphed = super::morph(Footprint::default(), content, 0.5, false, InterpolationDistribution::default(), List::default()).await;
let fill = fill_graphic_list_at(&morphed, 0).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 {
panic!("Expected a solid color fill, got {:?}", fill.element(0));
};
let color = *colors.element(0).expect("Color present");
assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}");
}
#[track_caller]
fn contains_segment(vector: Vector, target: PathSeg) {
let segments = vector.segment_iter().map(|x| x.1);