Skip to content

Commit ba7cbd8

Browse files
authored
Store an appearance's paint as a bare Graphic rather than a single-element List<Graphic> (#4435)
Rank the coverage's paint attribute down from List<Graphic> to Graphic
1 parent 69585b2 commit ba7cbd8

10 files changed

Lines changed: 215 additions & 84 deletions

File tree

editor/src/messages/portfolio/document/document_message_handler.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2760,9 +2760,7 @@ impl DocumentMessageHandler {
27602760
// A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something
27612761
let has_stroke = appearance.is_some_and(|appearance| {
27622762
appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke())
2763-
&& appearance
2764-
.first_paint_of(Cover::Stroke)
2765-
.is_some_and(|paint| paint.element(0).is_some_and(|graphic| !graphic.is_fully_transparent()))
2763+
&& appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_fully_transparent())
27662764
});
27672765

27682766
// No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip.

editor/src/messages/portfolio/document_migration.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use glam::{DVec2, IVec2};
1010
use graph_craft::application_io::resource::{DataSource, Resource, ResourceHash, ResourceId};
1111
use graph_craft::document::DocumentNode;
1212
use graph_craft::document::{DocumentNodeImplementation, NodeInput, value::TaggedValue};
13-
use graph_craft::{Type, item};
13+
use graph_craft::{Type, item, list};
1414
use graphene_std::Color;
1515
use graphene_std::ParameterRef;
1616
use graphene_std::ProtoNodeIdentifier;
@@ -1894,6 +1894,28 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
18941894
.set_input(&InputConnector::node(*node_id, graphene_std::vector::stroke::DashPatternInput), migrated, network_path);
18951895
}
18961896

1897+
// The stored no-paint sentinel was the `List<Graphic>` type default before the paint connectors ranked down to `Item<Graphic>`.
1898+
// This must run before the stale-List-default cleanup below, which would otherwise adopt the definition's default paint.
1899+
{
1900+
let legacy_no_paint = TaggedValue::TypeDefault(list!(graphene_std::Graphic));
1901+
let paint_parameters: &[ParameterRef] = &[graphene_std::vector::fill::FillInput.into(), graphene_std::vector::stroke::PaintInput.into()];
1902+
for parameter in paint_parameters {
1903+
if reference != DefinitionIdentifier::ProtoNode(parameter.node_identifier.clone()) {
1904+
continue;
1905+
}
1906+
let Some(NodeInput::Value { tagged_value, exposed }) = node.inputs.get(parameter.input_index) else {
1907+
continue;
1908+
};
1909+
if **tagged_value == legacy_no_paint {
1910+
document.network_interface.set_input(
1911+
&InputConnector::node_at_index(*node_id, parameter.input_index),
1912+
NodeInput::value(TaggedValue::no_paint(), *exposed),
1913+
network_path,
1914+
);
1915+
}
1916+
}
1917+
}
1918+
18971919
// The corner radius became the `BoxCorners` value type; convert any already-shaped rectangle that still stores a legacy corner input
18981920
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)
18991921
&& let Some(corner_input) = node.input(graphene_std::vector::generator_nodes::rectangle::CornerRadiusInput)

node-graph/graph-craft/src/document/value.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -707,14 +707,14 @@ impl TaggedValue {
707707
}
708708
}
709709

710-
/// The stored form of a paint input's red-slash "no paint" choice: the `List<Graphic>` type default, materializing as an empty paint list.
710+
/// The stored form of a paint input's red-slash "no paint" choice: the `Item<Graphic>` type default, materializing as a `Graphic::None` paint.
711711
pub fn no_paint() -> Self {
712-
TaggedValue::TypeDefault(list!(Graphic))
712+
TaggedValue::TypeDefault(item!(Graphic))
713713
}
714714

715-
/// Whether this is the `List<Graphic>` type default created by [`Self::no_paint`] (and by disconnecting a paint wire).
715+
/// Whether this is the `Item<Graphic>` type default created by [`Self::no_paint`] (and by disconnecting a paint wire).
716716
pub fn is_no_paint(&self) -> bool {
717-
matches!(self, TaggedValue::TypeDefault(td) if *td == list!(Graphic))
717+
matches!(self, TaggedValue::TypeDefault(td) if *td == item!(Graphic))
718718
}
719719
}
720720

node-graph/libraries/core-types/src/list.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ pub const ATTR_MIDPOINT: &str = "midpoint";
8080
/// Item's ordered list of paint passes, of type `Appearance`. Earlier coverages paint first, compositing below later ones.
8181
pub const ATTR_APPEARANCE: &str = "appearance";
8282
// TODO: Add a "fill_rule" attribute as a sibling of "paint" on the coverage list (uniform across covers) once a FillRule type ships
83-
/// Coverage's `List<Graphic>` paint (implicit default empty, painting nothing), on the
83+
/// Coverage's `Graphic` paint (implicit default `Graphic::None`, painting nothing), on the
8484
/// `List<Coverage>` inside an `Appearance`.
8585
pub const ATTR_PAINT: &str = "paint";
8686
/// Stroke coverage's line thickness (`f64`, implicit default `0.`), on the `Item<Cover>` inside a `Coverage`.

node-graph/libraries/graphic-types/src/appearance.rs

Lines changed: 131 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
//! Data uniform across all covers (the paint) rides the outer `List<Coverage>` so columnar presence holds,
33
//! while cover-specific data rides the inner `Item<Cover>`, reusing `ATTR_TRANSFORM` for the stroke-authoring space.
44
5-
use crate::graphic::{Graphic, is_paint_present};
5+
use crate::graphic::Graphic;
6+
use core_types::Color;
67
use core_types::graphene_hash::CacheHash;
78
use core_types::list::{ATTR_ALIGN, ATTR_APPEARANCE, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_PAINT, ATTR_TRANSFORM, ATTR_WEIGHT, Item, List};
9+
use raster_types::{CPU, GPU, Raster};
810
use vector_types::vector::style::{DashPattern, Stroke};
11+
use vector_types::{Gradient, Vector};
912

1013
/// The geometry-to-region operator a coverage applies before painting:
1114
/// the interior of the geometry (fill) or the region swept along its outline (stroke).
@@ -114,18 +117,18 @@ impl Coverage {
114117
}
115118
}
116119

117-
/// Builds an appearance row, eliding the paint attribute when it draws nothing.
118-
fn cover_row(coverage: Coverage, paint: List<Graphic>) -> Item<Coverage> {
120+
/// Builds an appearance row, eliding the paint attribute when it is the default none-paint.
121+
fn cover_row(coverage: Coverage, paint: Graphic) -> Item<Coverage> {
119122
let mut row = Item::new_from_element(coverage);
120-
if is_paint_present(&paint) {
123+
if paint != Graphic::default() {
121124
row.set_attribute(ATTR_PAINT, paint);
122125
}
123126
row
124127
}
125128

126129
impl Appearance {
127130
/// Creates an appearance holding a single coverage with the given paint.
128-
pub fn new_single(coverage: Coverage, paint: List<Graphic>) -> Self {
131+
pub fn new_single(coverage: Coverage, paint: Graphic) -> Self {
129132
Self(List::new_from_item(cover_row(coverage, paint)))
130133
}
131134

@@ -161,8 +164,8 @@ impl Appearance {
161164
}
162165

163166
/// The paint of the coverage at the given index, or `None` if the paint attribute is absent.
164-
pub fn paint_at(&self, index: usize) -> Option<&List<Graphic>> {
165-
self.0.attribute::<List<Graphic>>(ATTR_PAINT, index)
167+
pub fn paint_at(&self, index: usize) -> Option<&Graphic> {
168+
self.0.attribute::<Graphic>(ATTR_PAINT, index)
166169
}
167170

168171
/// The index of the first coverage of the given cover in paint order.
@@ -176,15 +179,13 @@ impl Appearance {
176179
}
177180

178181
/// The paint of the first coverage of the given cover, filtered to paint that draws something.
179-
pub fn first_paint_of(&self, cover: Cover) -> Option<&List<Graphic>> {
180-
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| is_paint_present(paint))
182+
pub fn first_paint_of(&self, cover: Cover) -> Option<&Graphic> {
183+
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| !paint.is_empty())
181184
}
182185

183186
/// Iterates the coverages in paint order together with their paint, which is `None` when absent or drawing nothing.
184-
pub fn covers_with_paints(&self) -> impl Iterator<Item = (&Coverage, Option<&List<Graphic>>)> {
185-
self.covers()
186-
.enumerate()
187-
.map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| is_paint_present(paint))))
187+
pub fn covers_with_paints(&self) -> impl Iterator<Item = (&Coverage, Option<&Graphic>)> {
188+
self.covers().enumerate().map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| !paint.is_empty())))
188189
}
189190

190191
/// Gathers the renderer's per-item reads in one walk of the coverage list.
@@ -199,7 +200,7 @@ impl Appearance {
199200
}
200201
}
201202

202-
let painted = |index| self.paint_at(index).filter(|paint| is_paint_present(paint));
203+
let painted = |index| self.paint_at(index).filter(|paint| !paint.is_empty());
203204
FillAndStroke {
204205
stroke: first_stroke.map(|(_, coverage)| coverage.stroke_params()),
205206
fill_paint: first_fill.and_then(painted),
@@ -218,12 +219,12 @@ impl Appearance {
218219
pub fn has_painted_cover(&self, cover: Cover) -> bool {
219220
self.covers()
220221
.enumerate()
221-
.any(|(index, coverage)| coverage.cover() == cover && self.paint_at(index).is_some_and(is_paint_present))
222+
.any(|(index, coverage)| coverage.cover() == cover && self.paint_at(index).is_some_and(|paint| !paint.is_empty()))
222223
}
223224

224225
/// Replaces the first coverage of the incoming cover in place (keeping its position in the paint order),
225226
/// or inserts a new row at the requested end of the paint order if none exists.
226-
pub fn replace_or_insert(&mut self, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
227+
pub fn replace_or_insert(&mut self, coverage: Coverage, paint: Graphic, placement: CoverPlacement) {
227228
if let Some(index) = self.first_index_of(coverage.cover()) {
228229
if let Some(element) = self.0.element_mut(index) {
229230
*element = coverage;
@@ -245,7 +246,7 @@ impl Appearance {
245246

246247
/// Sets the paint of the first coverage of the given cover, leaving its other parameters untouched.
247248
/// Returns `false` without changing anything if no coverage of that cover exists.
248-
pub fn set_paint_of(&mut self, cover: Cover, paint: List<Graphic>) -> bool {
249+
pub fn set_paint_of(&mut self, cover: Cover, paint: Graphic) -> bool {
249250
let Some(index) = self.first_index_of(cover) else { return false };
250251
self.0.set_attribute(ATTR_PAINT, index, paint);
251252
true
@@ -262,32 +263,117 @@ impl Appearance {
262263
#[derive(Debug, Default)]
263264
pub struct FillAndStroke<'a> {
264265
pub stroke: Option<Stroke>,
265-
pub fill_paint: Option<&'a List<Graphic>>,
266-
pub stroke_paint: Option<&'a List<Graphic>>,
266+
pub fill_paint: Option<&'a Graphic>,
267+
pub stroke_paint: Option<&'a Graphic>,
267268
/// Whether the first stroke coverage sits before the first fill in the paint order, painting below it.
268269
pub stroke_below: bool,
269270
}
270271

271272
/// Stamps a coverage into the item's `ATTR_APPEARANCE` cell, creating the attribute if absent.
272273
/// The coverage replaces the first same-cover one in place, or lands at the placement end of the paint order.
273-
pub fn stamp_coverage<T>(item: &mut Item<T>, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
274+
pub fn stamp_coverage<T>(item: &mut Item<T>, coverage: Coverage, paint: Graphic, placement: CoverPlacement) {
274275
item.attribute_mut_or_insert_default::<Appearance>(ATTR_APPEARANCE).replace_or_insert(coverage, paint, placement);
275276
}
276277

278+
// ================
279+
// TRAIT: IntoPaint
280+
// ================
281+
282+
/// Converts the types accepted by a paint input into the canonical `Graphic` stored in the `ATTR_PAINT` attribute.
283+
/// `List<Graphic>` deliberately has no impl: a multi-element paint is a type error.
284+
pub trait IntoPaint: Clone + Send + Sync + Default + std::fmt::Debug + PartialEq + CacheHash + 'static {
285+
fn into_paint(self) -> Graphic;
286+
}
287+
288+
impl IntoPaint for Item<Graphic> {
289+
fn into_paint(self) -> Graphic {
290+
// Wrapping to keep the record's attributes would nest the paint as a group, changing how it renders
291+
self.into_element()
292+
}
293+
}
294+
295+
impl IntoPaint for Item<Vector> {
296+
fn into_paint(self) -> Graphic {
297+
Graphic::VectorList(List::new_from_item(self))
298+
}
299+
}
300+
301+
impl IntoPaint for Item<Raster<CPU>> {
302+
fn into_paint(self) -> Graphic {
303+
Graphic::RasterCPUList(List::new_from_item(self))
304+
}
305+
}
306+
307+
// No Item<Raster<GPU>> impl: GPU rasters have no Default, which the trait bounds require of the element
308+
309+
impl IntoPaint for Item<Color> {
310+
fn into_paint(self) -> Graphic {
311+
Graphic::ColorList(List::new_from_item(self))
312+
}
313+
}
314+
315+
impl IntoPaint for Item<Gradient> {
316+
fn into_paint(self) -> Graphic {
317+
Graphic::GradientList(List::new_from_item(self))
318+
}
319+
}
320+
321+
impl IntoPaint for Item<String> {
322+
fn into_paint(self) -> Graphic {
323+
Graphic::TextList(List::new_from_item(self))
324+
}
325+
}
326+
327+
impl IntoPaint for List<Vector> {
328+
fn into_paint(self) -> Graphic {
329+
Graphic::VectorList(self)
330+
}
331+
}
332+
333+
impl IntoPaint for List<Raster<CPU>> {
334+
fn into_paint(self) -> Graphic {
335+
Graphic::RasterCPUList(self)
336+
}
337+
}
338+
339+
impl IntoPaint for List<Raster<GPU>> {
340+
fn into_paint(self) -> Graphic {
341+
Graphic::RasterGPUList(self)
342+
}
343+
}
344+
345+
impl IntoPaint for List<Color> {
346+
fn into_paint(self) -> Graphic {
347+
Graphic::ColorList(self)
348+
}
349+
}
350+
351+
impl IntoPaint for List<Gradient> {
352+
fn into_paint(self) -> Graphic {
353+
Graphic::GradientList(self)
354+
}
355+
}
356+
357+
impl IntoPaint for List<String> {
358+
fn into_paint(self) -> Graphic {
359+
Graphic::TextList(self)
360+
}
361+
}
362+
277363
#[cfg(test)]
278364
mod tests {
279365
use super::*;
280-
use core_types::Color;
366+
use core_types::list::ATTR_POSITION;
281367
use glam::{DAffine2, DVec2};
282368
use vector_types::vector::style::{StrokeAlign, StrokeCap, StrokeJoin};
283369

284-
fn solid_paint(color: Color) -> List<Graphic> {
285-
List::new_from_element(Graphic::ColorList(List::new_from_element(color)))
370+
fn solid_paint(color: Color) -> Graphic {
371+
Graphic::ColorList(List::new_from_element(color))
286372
}
287373

288374
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
289375
let paint = appearance.paint_at(index)?;
290-
let Some(Graphic::ColorList(colors)) = paint.element(0) else { return None };
376+
let Graphic::ColorList(colors) = paint else { return None };
291377
colors.element(0).copied()
292378
}
293379

@@ -374,7 +460,7 @@ mod tests {
374460
#[test]
375461
fn painted_cover_distinguishes_none_paint_from_absence() {
376462
let mut appearance = Appearance::default();
377-
appearance.replace_or_insert(Coverage::new_fill(), List::new_from_element(Graphic::None), CoverPlacement::Above);
463+
appearance.replace_or_insert(Coverage::new_fill(), Graphic::None, CoverPlacement::Above);
378464

379465
assert!(appearance.has_cover(Cover::Fill), "a none-painted coverage still exists");
380466
assert!(!appearance.has_painted_cover(Cover::Fill), "a none-painted coverage draws nothing");
@@ -384,4 +470,24 @@ mod tests {
384470
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
385471
assert!(appearance.has_painted_cover(Cover::Fill));
386472
}
473+
474+
#[test]
475+
fn list_paint_becomes_one_graphic_holding_every_element() {
476+
let mut colors = List::new_from_element(Color::RED);
477+
colors.push(Item::new_from_element(Color::BLUE));
478+
479+
let paint = colors.into_paint();
480+
let Graphic::ColorList(inner) = &paint else { panic!("expected a color graphic") };
481+
assert_eq!(inner.len(), 2, "a list paint is one graphic holding all its elements");
482+
}
483+
484+
#[test]
485+
fn item_paint_keeps_its_attributes_on_the_inner_row() {
486+
let color = Item::new_from_element(Color::RED).with_attribute(ATTR_POSITION, 0.25_f64);
487+
488+
let paint = color.into_paint();
489+
let Graphic::ColorList(inner) = &paint else { panic!("expected a color graphic") };
490+
assert_eq!(inner.len(), 1);
491+
assert_eq!(inner.attribute::<f64>(ATTR_POSITION, 0), Some(&0.25));
492+
}
387493
}

0 commit comments

Comments
 (0)