From 46e0a4f2f5232737405f513f0f5b725e10ea4deb Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Wed, 9 Sep 2026 22:18:50 +0000 Subject: [PATCH] Read every paint through the cascaded appearance The renderer, metadata collection, hit testing, and clip-path reduction all resolve a lane's paint as its own declared appearance or the nearest ancestor's through the cascade, taking the first fill and stroke coverages in one walk: the coverage's stroke parameters replace the element-resident stroke, the paint cells' interior lists feed the paint renderers, and the paint order reads off the coverage row order, which the stroke nodes now place from their paint order input. The paint overlay and its transmute dissolve into an inherited-appearance parameter on the vector-level bodies, with the Render trait untouched. Mask clones paint through a single black-fill appearance, and every row-building node stamps the appearance beside the legacy markers it still writes, so renders stay identical until the markers go. Co-Authored-By: Claude Fable 5 --- .../graphic-types/src/graphic/mod.rs | 6 +- .../graphic-types/src/graphic/paint.rs | 96 ++------- .../libraries/rendering/src/renderer.rs | 203 +++++++++++------- node-graph/nodes/graphic/src/graphic.rs | 8 + node-graph/nodes/path-bool/src/lib.rs | 32 ++- node-graph/nodes/vector/src/vector_nodes.rs | 161 ++++++++++++-- 6 files changed, 325 insertions(+), 181 deletions(-) diff --git a/node-graph/libraries/graphic-types/src/graphic/mod.rs b/node-graph/libraries/graphic-types/src/graphic/mod.rs index 2de8cd5856..b6b53aa36f 100644 --- a/node-graph/libraries/graphic-types/src/graphic/mod.rs +++ b/node-graph/libraries/graphic-types/src/graphic/mod.rs @@ -8,7 +8,7 @@ pub use glue::{map_groups_to_owned, map_groups_to_persistent, map_groups_to_resi pub(crate) use legacy::run_to_legacy_list; pub use legacy::{group_to_legacy_graphic, group_to_legacy_list, map_groups_to_legacy, map_paint_attrs_to_legacy, run_to_list}; pub use paint::{ - LanePaint, PaintColumns, PaintOverlay, PaintOverlayColumn, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_graphics, set_paint_attribute, set_paint_attribute_at, + LanePaint, PaintColumns, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_cell_rows, paint_graphics, set_paint_attribute, set_paint_attribute_at, vector_can_reduce_to_clip_path, }; pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows}; @@ -399,9 +399,9 @@ impl<'e> Graphic<'e> { } } - pub fn can_reduce_to_clip_path(&self) -> bool { + pub fn can_reduce_to_clip_path(&self, inherited_appearance: Option<&Appearance>) -> bool { match self { - Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector)), + Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector), inherited_appearance), _ => false, } } diff --git a/node-graph/libraries/graphic-types/src/graphic/paint.rs b/node-graph/libraries/graphic-types/src/graphic/paint.rs index 1200a69a2d..ad9c8dfeab 100644 --- a/node-graph/libraries/graphic-types/src/graphic/paint.rs +++ b/node-graph/libraries/graphic-types/src/graphic/paint.rs @@ -40,15 +40,26 @@ where /// Whether every lane of a vector source draws as a plain clip path: fully /// opaque, fill absent or opaque, stroke invisible or fully transparent. -pub fn vector_can_reduce_to_clip_path>(source: &S) -> bool { +pub fn vector_can_reduce_to_clip_path>(source: &S, inherited_appearance: Option<&Appearance>) -> bool { (0..source.lane_count()).all(|index| { - let Some(element) = source.element(index) else { return false }; + if source.element(index).is_none() { + return false; + } let opacity: f64 = source.attr::(index); - let fill_opaque_or_absent = paint_graphics::(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque())); + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); - let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) - || paint_graphics::(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent())); + let fill_opaque_or_absent = resolved + .fill_paint + .and_then(paint_cell_rows) + .is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque())); + + let stroke_invisible_or_transparent = resolved.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) + || resolved + .stroke_paint + .and_then(paint_cell_rows) + .is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent())); opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent }) @@ -168,76 +179,13 @@ impl<'a> PaintReach<'a> { } } -/// A source with a lane's paint forced over its fill and stroke columns, -/// reaching the interiors the legacy conversion's paint push reached. -pub struct PaintOverlay<'a, S> { - inner: &'a S, - paint: LanePaint<'a>, -} - -impl<'a, S> PaintOverlay<'a, S> { - pub fn new(inner: &'a S, paint: LanePaint<'a>) -> Self { - Self { inner, paint } - } -} - -pub struct PaintOverlayColumn<'a, S: LaneSource + 'a, A: Attribute> { - inner: S::Column<'a, A>, - forced: Option>, -} - -impl<'a, S: LaneSource, A: Attribute> LaneColumn<'a, A> for PaintOverlayColumn<'a, S, A> { - fn try_get(&self, lane: usize) -> Option> { - match self.forced { - Some(forced) => Some(forced), - None => self.inner.try_get(lane), - } - } -} - -/// The forced value for the marker `A`: the lane paint where `A` is this -/// crate's fill or stroke marker, absent otherwise. -fn forced_paint<'a, A: Attribute>(paint: LanePaint<'a>) -> Option> { - let slot = match A::NAME { - name if name == Fill::NAME => paint.fill, - name if name == Stroke::NAME => paint.stroke, +/// The paint a coverage row's cell holds, in the canonical `List` form the paint +/// renderers consume: this crate's writers carry the list as one graphic cell, and a bare +/// cell of any other form is treated as paint that draws nothing. +pub fn paint_cell_rows<'a>(cell: &'a Graphic<'static>) -> Option<&'a List>> { + match cell { + Graphic::Graphic(list) => Some(list).filter(|list| is_paint_present(list)), _ => None, - }?; - assert_eq!( - std::any::TypeId::of::>(), - std::any::TypeId::of::>>>(), - "attribute `{}` is declared at another value type than this crate's paint form", - A::NAME - ); - assert_eq!( - size_of::>(), - size_of::>>>(), - "the paint value form must span the marker's value" - ); - // SAFETY: the census admits one value type per attribute name, so a `fill` or `stroke` marker carries this crate's `Option<&List>` at the asserted size. - Some(unsafe { std::mem::transmute_copy::>, A::Value<'a>>(&Some(slot)) }) -} - -impl<'a, S: LaneSource> LaneSource for PaintOverlay<'a, S> { - type Element = S::Element; - type Column<'b, A: Attribute> - = PaintOverlayColumn<'b, S, A> - where - Self: 'b; - - fn lane_count(&self) -> usize { - self.inner.lane_count() - } - - fn element(&self, lane: usize) -> Option<&S::Element> { - self.inner.element(lane) - } - - fn column(&self) -> PaintOverlayColumn<'_, S, A> { - PaintOverlayColumn { - inner: self.inner.column::(), - forced: forced_paint::(self.paint), - } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index c79e3715e9..3d02a85bcf 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -23,8 +23,9 @@ use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; use graphene_hash::CacheHashWrapper; use graphene_resource::Resource; -use graphic_types::graphic::{PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path}; -use graphic_types::markers::{EditorMergedLayers, Fill, Stroke}; +use graphic_types::appearance::{Appearance, Coverage}; +use graphic_types::graphic::{PaintColumns, PaintReach, is_paint_present, paint_cell_rows, set_paint_attribute, vector_can_reduce_to_clip_path}; +use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::vector_types::gradient::{GradientStops, GradientType}; use graphic_types::vector_types::markers::{GradientType as GradientTypeAttr, SpreadMethod}; @@ -207,6 +208,11 @@ pub struct RenderContext { pub resource_overrides: Vec<(peniko::ImageBrush, Texture)>, } +/// The single black-fill appearance a mask clone paints with, at full alpha so the mask fully covers the interior. +fn black_fill_appearance() -> Appearance { + Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(List::new_from_element(Graphic::Color(Color::BLACK)))) +} + /// The alpha multiplier a paint row's opacity attributes apply when it serves as a paint. /// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it. pub(crate) fn paint_row_opacity(list: &List, index: usize, for_mask: bool) -> f32 { @@ -624,7 +630,7 @@ impl Render for Graphic<'_> { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { match self { Graphic::Graphic(list) => list.render_svg(render, render_params), - Graphic::Vector(vector) => render_vector_svg(&Single(vector), render, render_params), + Graphic::Vector(vector) => render_vector_svg(&Single(vector), None, render, render_params), Graphic::RasterCPU(raster) => render_raster_cpu_svg(&Single(raster), render, render_params), Graphic::RasterGPU(_) => (), Graphic::Color(color) => render_color_svg(&Single(color), render, render_params), @@ -637,7 +643,7 @@ impl Render for Graphic<'_> { fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { match self { Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params), - Graphic::Vector(vector) => render_vector_vello(&Single(vector), scene, transform, context, render_params), + Graphic::Vector(vector) => render_vector_vello(&Single(vector), None, scene, transform, context, render_params), Graphic::RasterCPU(raster) => render_raster_cpu_vello(&Single(raster), scene, transform, render_params), Graphic::RasterGPU(raster) => render_raster_gpu_vello(&Single(raster), scene, transform, context, render_params), Graphic::Color(color) => render_color_vello(&Single(color), scene, render_params), @@ -677,7 +683,7 @@ impl Render for Graphic<'_> { fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) { match element { - Graphic::Vector(vector) if reach.applies() => render_vector_svg(&PaintOverlay::new(&Single(vector), reach.paint), render, render_params), + Graphic::Vector(vector) => render_vector_svg(&Single(vector), reach.appearance, render, render_params), Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach.nested(), render, render_params), Graphic::Group(group) => render_group_svg(group, reach, render, render_params), _ => element.render_svg(render, render_params), @@ -686,7 +692,7 @@ fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: & fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { match element { - Graphic::Vector(vector) if reach.applies() => render_vector_vello(&PaintOverlay::new(&Single(vector), reach.paint), scene, transform, context, render_params), + Graphic::Vector(vector) => render_vector_vello(&Single(vector), reach.appearance, scene, transform, context, render_params), Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach.nested(), scene, transform, context, render_params), Graphic::Group(group) => render_group_vello(group, reach, scene, transform, context, render_params), _ => element.render_to_vello(scene, transform, context, render_params), @@ -695,13 +701,12 @@ fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene: fn element_can_reduce_to_clip_path<'a>(element: &'a Graphic, reach: PaintReach<'a>) -> bool { match element { - Graphic::Vector(vector) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&Single(vector), reach.paint)), + Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&Single(vector), reach.appearance), Graphic::Group(group) => match RunView::::new(&group.content) { - Some(run) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&run, reach.paint)), - Some(run) => vector_can_reduce_to_clip_path(&run), + Some(run) => vector_can_reduce_to_clip_path(&run, reach.appearance), None => false, }, - _ => element.can_reduce_to_clip_path(), + _ => element.can_reduce_to_clip_path(reach.appearance), } } @@ -732,8 +737,7 @@ fn collect_element_metadata<'a>( match element { Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach.nested(), metadata, footprint, element_id), - Graphic::Vector(vector) if reach.applies() => collect_vector_metadata(&PaintOverlay::new(&Single(vector), reach.paint), metadata, footprint, element_id), - Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), metadata, footprint, element_id), + Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), reach.appearance, metadata, footprint, element_id), Graphic::RasterCPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id), Graphic::RasterGPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id), Graphic::Color(_) => {} @@ -775,8 +779,7 @@ fn collect_group_row_metadata(group: &Group, metadata: &mut RenderMetadata, elem fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, click_targets: &mut Vec) { match element { Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach.nested(), click_targets), - Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets), - Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets), + Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), reach.appearance, click_targets), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(click_targets), Graphic::Color(_) | Graphic::Gradient(_) => {} Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), click_targets), @@ -787,8 +790,7 @@ fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReac fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, outlines: &mut Vec) { match element { Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach.nested(), outlines), - Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines), - Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines), + Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), reach.appearance, outlines), Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(outlines), Graphic::Color(_) | Graphic::Gradient(_) => {} Graphic::Text(text) => add_text_upstream_click_targets(&Single(text), outlines), @@ -803,10 +805,7 @@ fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut Sv if let Some(run) = RunView::::new(item) { render_graphic_svg_with(&run, reach.into_group_graphics(), render, render_params) } else if let Some(run) = RunView::::new(item) { - match reach.applies() { - true => render_vector_svg(&PaintOverlay::new(&run, reach.paint), render, render_params), - false => render_vector_svg(&run, render, render_params), - } + render_vector_svg(&run, reach.appearance, render, render_params) } else if let Some(run) = RunView::>::new(item) { render_raster_cpu_svg(&run, render, render_params) } else if item.typed_lanes::>().is_some() { @@ -824,10 +823,7 @@ fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut S if let Some(run) = RunView::::new(item) { render_graphic_vello_with(&run, reach.into_group_graphics(), scene, transform, context, render_params) } else if let Some(run) = RunView::::new(item) { - match reach.applies() { - true => render_vector_vello(&PaintOverlay::new(&run, reach.paint), scene, transform, context, render_params), - false => render_vector_vello(&run, scene, transform, context, render_params), - } + render_vector_vello(&run, reach.appearance, scene, transform, context, render_params) } else if let Some(run) = RunView::>::new(item) { render_raster_cpu_vello(&run, scene, transform, render_params) } else if let Some(run) = RunView::>::new(item) { @@ -849,10 +845,7 @@ fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata: if let Some(run) = RunView::::new(item) { collect_graphic_metadata_with(&run, reach.into_group_graphics(), metadata, footprint, element_id) } else if let Some(run) = RunView::::new(item) { - match reach.applies() { - true => collect_vector_metadata(&PaintOverlay::new(&run, reach.paint), metadata, footprint, element_id), - false => collect_vector_metadata(&run, metadata, footprint, element_id), - } + collect_vector_metadata(&run, reach.appearance, metadata, footprint, element_id) } else if let Some(run) = RunView::>::new(item) { collect_raster_metadata(&run, metadata, footprint, element_id) } else if let Some(run) = RunView::>::new(item) { @@ -868,10 +861,7 @@ fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>, if let Some(run) = RunView::::new(item) { add_graphic_upstream_click_targets_with(&run, reach.into_group_graphics(), click_targets) } else if let Some(run) = RunView::::new(item) { - match reach.applies() { - true => add_vector_upstream_click_targets(&PaintOverlay::new(&run, reach.paint), click_targets), - false => add_vector_upstream_click_targets(&run, click_targets), - } + add_vector_upstream_click_targets(&run, reach.appearance, click_targets) } else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() { add_raster_upstream_click_targets(click_targets) } else if let Some(run) = RunView::::new(item) { @@ -884,10 +874,7 @@ fn add_group_upstream_outline_targets<'a>(group: &'a Group, reach: PaintReach<'a if let Some(run) = RunView::::new(item) { add_graphic_upstream_outline_targets_with(&run, reach.into_group_graphics(), outlines) } else if let Some(run) = RunView::::new(item) { - match reach.applies() { - true => add_vector_upstream_outline_targets(&PaintOverlay::new(&run, reach.paint), outlines), - false => add_vector_upstream_outline_targets(&run, outlines), - } + add_vector_upstream_outline_targets(&run, reach.appearance, outlines) } else if item.typed_lanes::>().is_some() || item.typed_lanes::>().is_some() { add_raster_upstream_click_targets(outlines) } else if let Some(run) = RunView::::new(item) { @@ -1343,7 +1330,7 @@ impl Render for List> { } } -fn render_vector_svg>(source: &S, render: &mut SvgRender, render_params: &RenderParams) { +fn render_vector_svg>(source: &S, inherited_appearance: Option<&Appearance>, render: &mut SvgRender, render_params: &RenderParams) { for index in 0..source.lane_count() { let Some(vector) = source.element(index) else { continue }; let item_transform: DAffine2 = source.attr::(index); @@ -1351,8 +1338,17 @@ fn render_vector_svg>(source: &S, render: &mut S let opacity_attr: f64 = source.attr::(index); let opacity_fill_attr: f64 = source.attr::(index); + // The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let mut resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + // The paint order rides the coverage list's row order + if let Some(stroke) = &mut resolved.stroke { + stroke.paint_order = if resolved.stroke_below { PaintOrder::StrokeBelow } else { PaintOrder::StrokeAbove }; + } + let element_stroke = resolved.stroke.as_ref(); + // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform - let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); + let has_real_stroke = element_stroke.filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); @@ -1372,26 +1368,26 @@ fn render_vector_svg>(source: &S, render: &mut S path.push_str(bezpath.to_svg().as_str()); } - let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) { + let mask_type = if element_stroke.map(|x| x.align) == Some(StrokeAlign::Inside) { MaskType::Clip } else { MaskType::Mask }; - let fill_graphic_list = paint_graphics::(source, index); + let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows); let fill_graphic = fill_graphic_list.and_then(|l| l.element(0)); - let stroke_graphic_list = paint_graphics::(source, index); + let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows); let stroke_graphic = stroke_graphic_list.and_then(|l| l.element(0)); let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); let can_draw_aligned_stroke = path_is_closed - && vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) + && element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; - let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); + let wants_stroke_below = element_stroke.map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow); let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let use_face_fill = vector.use_face_fill(); @@ -1418,6 +1414,7 @@ fn render_vector_svg>(source: &S, render: &mut S // The wrapping SVG group (above) handles the user-set opacity. 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)); + mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance()); let vector_item = List::new_from_item(mask_item); (id, mask_type, vector_item) @@ -1452,7 +1449,7 @@ fn render_vector_svg>(source: &S, render: &mut S if let Some((ref id, mask_type, ref vector_item)) = push_id { let mut svg = SvgRender::new(); vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); - let stroke = vector.stroke.as_ref().unwrap(); + let stroke = element_stroke.unwrap(); // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` let (largest_scale, _) = singular_values(applied_stroke_transform); let inflation = stroke.max_aabb_inflation(true) * largest_scale; @@ -1479,9 +1476,7 @@ fn render_vector_svg>(source: &S, render: &mut S render_params.aligned_strokes = can_draw_aligned_stroke; render_params.override_paint_order = override_paint_order; - let stroke_shape_attribute = vector - .stroke - .as_ref() + let stroke_shape_attribute = element_stroke .map(|stroke| { if stroke_graphic_list.is_some_and(is_paint_present) { stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke) @@ -1492,7 +1487,7 @@ fn render_vector_svg>(source: &S, render: &mut S .unwrap_or_default(); // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback - let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); + let stroke_visible = element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); let stroke_attribute = if stroke_visible { stroke_graphic_list .map(|list| { @@ -1555,7 +1550,14 @@ fn render_vector_svg>(source: &S, render: &mut S } } -fn render_vector_vello>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { +fn render_vector_vello>( + source: &S, + inherited_appearance: Option<&Appearance>, + scene: &mut Scene, + parent_transform: DAffine2, + context: &mut RenderContext, + render_params: &RenderParams, +) { for index in 0..source.lane_count() { use graphic_types::vector_types::vector; @@ -1565,7 +1567,20 @@ fn render_vector_vello>(source: &S, scene: &mut let opacity_attr: f64 = source.attr::(index); let opacity_fill_attr: f64 = source.attr::(index); let multiplied_transform = parent_transform * item_transform; - let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); + + // The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let mut resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + // The paint order rides the coverage list's row order + if let Some(stroke) = &mut resolved.stroke { + stroke.paint_order = if resolved.stroke_below { + vector::style::PaintOrder::StrokeBelow + } else { + vector::style::PaintOrder::StrokeAbove + }; + } + + let has_real_stroke = resolved.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); let mut element_transform = set_stroke_transform @@ -1589,8 +1604,8 @@ fn render_vector_vello>(source: &S, scene: &mut } } - let fill_graphic_list = paint_graphics::(source, index); - let stroke_graphic_list = paint_graphics::(source, index); + let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows); + let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows); // If we're using opacity or a blend mode, we need to push a layer let blend_mode = match render_params.render_mode { @@ -1602,7 +1617,7 @@ fn render_vector_vello>(source: &S, scene: &mut // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. - let stroke = element.stroke.as_ref(); + let stroke = resolved.stroke.as_ref(); let stroke_fully_transparent = stroke_graphic_list.is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent())); let can_draw_aligned_stroke = !stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); @@ -1764,6 +1779,7 @@ fn render_vector_vello>(source: &S, scene: &mut // The outer opacity/blend layer (above) handles the user-set opacity. 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)); + mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance()); let vector_list = List::new_from_item(mask_item); let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); @@ -1833,7 +1849,13 @@ fn render_vector_vello>(source: &S, scene: &mut } } -fn collect_vector_metadata>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { +fn collect_vector_metadata>( + source: &S, + inherited_appearance: Option<&Appearance>, + metadata: &mut RenderMetadata, + footprint: Footprint, + caller_element_id: Option, +) { // Aggregate all items' targets per element_id so multi-item lists (e.g. 'Text' node with "Separate Glyphs" active) produce hit areas for every glyph. // Targets are baked relative to item 0's transform since `Graphic::collect_metadata` records that as `local_transforms[element_id]`. let item_zero_transform: DAffine2 = if source.lane_count() > 0 { source.attr::(0) } else { DAffine2::IDENTITY }; @@ -1852,6 +1874,10 @@ fn collect_vector_metadata>(source: &S, metadata let layer_path: &[NodeId] = source.attr::(index); let layer = layer_path.last().copied(); + // The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + if let Some(element_id) = caller_element_id.or(layer) { // When recovering element_id from the item's editor:layer_path tag (because the caller // passed None), also store the transform metadata that Graphic::collect_metadata @@ -1867,12 +1893,12 @@ fn collect_vector_metadata>(source: &S, metadata let item_relative_transform = item_zero_inverse * transform; let mut click_targets_unwrapped = Vec::new(); - extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform); + extend_targets_from_vector(&mut click_targets_unwrapped, &resolved, click_target_vector, item_relative_transform); accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); // Outlines always use source geometry so the visual outline reflects actual letterforms let mut outlines_unwrapped = Vec::new(); - extend_targets_from_vector(&mut outlines_unwrapped, source, index, element, item_relative_transform); + extend_targets_from_vector(&mut outlines_unwrapped, &resolved, element, item_relative_transform); accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); // Source geometry (not the click-target override) so editing tools work on letterforms. @@ -1882,10 +1908,10 @@ fn collect_vector_metadata>(source: &S, metadata if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) { e.insert(Arc::new(element.clone())); - if let Some(fill_graphic) = source.attr::(index).filter(|list| is_paint_present(list)) { + if let Some(fill_graphic) = resolved.fill_paint.and_then(paint_cell_rows) { metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone())); } - if let Some(stroke_graphic) = source.attr::(index).filter(|list| is_paint_present(list)) { + if let Some(stroke_graphic) = resolved.stroke_paint.and_then(paint_cell_rows) { metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.clone())); } } @@ -1915,7 +1941,7 @@ fn collect_vector_metadata>(source: &S, metadata } } -fn add_vector_upstream_click_targets>(source: &S, click_targets: &mut Vec) { +fn add_vector_upstream_click_targets>(source: &S, inherited_appearance: Option<&Appearance>, click_targets: &mut Vec) { for index in 0..source.lane_count() { let Some(element) = source.element(index) else { continue }; let transform: DAffine2 = source.attr::(index); @@ -1923,39 +1949,45 @@ fn add_vector_upstream_click_targets>(source: &S // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) let vector = source.attr::(index).unwrap_or(element); - extend_targets_from_vector(click_targets, source, index, vector, transform); + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + + extend_targets_from_vector(click_targets, &resolved, vector, transform); } } -fn add_vector_upstream_outline_targets>(source: &S, outlines: &mut Vec) { +fn add_vector_upstream_outline_targets>(source: &S, inherited_appearance: Option<&Appearance>, outlines: &mut Vec) { // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms for index in 0..source.lane_count() { let Some(element) = source.element(index) else { continue }; let transform: DAffine2 = source.attr::(index); - extend_targets_from_vector(outlines, source, index, element, transform); + let appearance = Appearance::cascade(source.attr::(index), inherited_appearance); + let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default(); + + extend_targets_from_vector(outlines, &resolved, element, transform); } } impl Render for List { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - render_vector_svg(self, render, render_params) + render_vector_svg(self, None, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - render_vector_vello(self, scene, parent_transform, context, render_params) + render_vector_vello(self, None, scene, parent_transform, context, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { - collect_vector_metadata(self, metadata, footprint, caller_element_id) + collect_vector_metadata(self, None, metadata, footprint, caller_element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - add_vector_upstream_click_targets(self, click_targets) + add_vector_upstream_click_targets(self, None, click_targets) } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { - add_vector_upstream_outline_targets(self, outlines) + add_vector_upstream_outline_targets(self, None, outlines) } fn new_ids_from_hash(&mut self, reference: Option) { @@ -1967,15 +1999,15 @@ impl Render for List { /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. -fn extend_targets_from_vector>(targets: &mut Vec, source: &S, index: usize, geometry: &Vector, transform: DAffine2) { - let filled = has_paint::(source, index); +fn extend_targets_from_vector(targets: &mut Vec, resolved: &graphic_types::appearance::FillAndStroke<'_>, geometry: &Vector, transform: DAffine2) { + let filled = resolved.fill_paint.and_then(paint_cell_rows).is_some(); let mut subpaths: Vec> = geometry.stroke_bezier_paths().collect(); let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); // Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side, // so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths. - let stroke_width = geometry.stroke.as_ref().map_or(0., |stroke| { + let stroke_width = resolved.stroke.as_ref().map_or(0., |stroke| { if stroke.align.is_not_centered() && all_subpaths_closed { stroke.weight * 2. } else { @@ -2954,23 +2986,23 @@ impl Render for RunView<'_, Graphic<'_>> { impl Render for RunView<'_, Vector> { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { - render_vector_svg(self, render, render_params) + render_vector_svg(self, None, render, render_params) } fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { - render_vector_vello(self, scene, parent_transform, context, render_params) + render_vector_vello(self, None, scene, parent_transform, context, render_params) } fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option) { - collect_vector_metadata(self, metadata, footprint, caller_element_id) + collect_vector_metadata(self, None, metadata, footprint, caller_element_id) } fn add_upstream_click_targets(&self, click_targets: &mut Vec) { - add_vector_upstream_click_targets(self, click_targets) + add_vector_upstream_click_targets(self, None, click_targets) } fn add_upstream_outline_targets(&self, outlines: &mut Vec) { - add_vector_upstream_outline_targets(self, outlines) + add_vector_upstream_outline_targets(self, None, outlines) } } @@ -3120,6 +3152,11 @@ mod group_walk_tests { List::new_from_element(Graphic::Color(Color::from_rgbaf32(0.8, 0.2, 0.33, 1.).unwrap())) } + /// The appearance the fill node stamps beside the legacy fill marker, so test content mirrors node output. + fn fill_appearance(paint: &List>) -> Appearance { + Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint.clone())) + } + fn rendered_svg(render: impl FnOnce(&mut SvgRender)) -> (String, String) { let mut svg_render = SvgRender::new(); render(&mut svg_render); @@ -3132,9 +3169,11 @@ mod group_walk_tests { let paint = color_paint(); let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(3., 1.))]; let arena = core_types::arena::Arena::new(1 << 16).unwrap(); - let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0)], 2).unwrap(); + let appearance = fill_appearance(&paint); + let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0), FieldWrite::of::(0)], 2).unwrap(); let lane = builder.push(vectors[0].clone()).unwrap(); builder.attr::(lane, Some(&paint)); + builder.attr::(lane, Some(&appearance)); builder.push(vectors[1].clone()).unwrap(); let item = builder.finish(); let group = Group { row: None, content: item }; @@ -3151,9 +3190,11 @@ mod group_walk_tests { let paint = color_paint(); let inner = Graphic::Vector(unit_square_at(DVec2::ZERO)); let arena = core_types::arena::Arena::new(1 << 16).unwrap(); - let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0)], 1).unwrap(); + let appearance = fill_appearance(&paint); + let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0), FieldWrite::of::(0)], 1).unwrap(); let lane = builder.push(inner.clone()).unwrap(); builder.attr::(lane, Some(&paint)); + builder.attr::(lane, Some(&appearance)); let item = builder.finish(); let group = Group { row: None, content: item }; @@ -3170,9 +3211,11 @@ mod group_walk_tests { let paint = color_paint(); let vectors = [unit_square_at(DVec2::ZERO)]; let arena = core_types::arena::Arena::new(1 << 16).unwrap(); - let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0)], 1).unwrap(); + let appearance = fill_appearance(&paint); + let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0), FieldWrite::of::(0)], 1).unwrap(); let lane = builder.push(vectors[0].clone()).unwrap(); builder.attr::(lane, Some(&paint)); + builder.attr::(lane, Some(&appearance)); let item = builder.finish(); let group = Group { row: None, content: item }; @@ -3195,9 +3238,11 @@ mod group_walk_tests { let paint = color_paint(); let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(2., 2.))]; let arena = core_types::arena::Arena::new(1 << 16).unwrap(); - let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0)], 2).unwrap(); + let appearance = fill_appearance(&paint); + let mut builder = RunBuilder::new(&arena, element_write_hashed::(), &[FieldWrite::of::(0), FieldWrite::of::(0)], 2).unwrap(); let lane = builder.push(vectors[0].clone()).unwrap(); builder.attr::(lane, Some(&paint)); + builder.attr::(lane, Some(&appearance)); builder.push(vectors[1].clone()).unwrap(); let item = builder.finish(); let group = Group { row: None, content: item }; diff --git a/node-graph/nodes/graphic/src/graphic.rs b/node-graph/nodes/graphic/src/graphic.rs index de7dba1917..6a02ddd7f1 100644 --- a/node-graph/nodes/graphic/src/graphic.rs +++ b/node-graph/nodes/graphic/src/graphic.rs @@ -183,6 +183,7 @@ fn mirror_lane<'e, T: Clone + Default + Send + Sync + 'static>( Attr<'e, TransformAttr>, Attr<'e, graphic_types::markers::Fill>, Attr<'e, graphic_types::markers::Stroke>, + Attr<'e, graphic_types::markers::Appearance>, Attr<'e, core_types::attribute::BlendMode>, Attr<'e, core_types::attribute::Opacity>, Attr<'e, core_types::attribute::OpacityFill>, @@ -227,6 +228,10 @@ where } let fill = park_paint(legacy.attribute::>>(graphic_types::ATTR_FILL, source).cloned().flatten())?; let stroke = park_paint(legacy.attribute::>>(graphic_types::ATTR_STROKE, source).cloned().flatten())?; + let appearance = match legacy.attribute::(graphic_types::ATTR_APPEARANCE, source).cloned() { + Some(appearance) => Some(&*arena.alloc_sized_keyed(appearance, 0).ok_or_else(exhausted)?.0), + None => None, + }; let layer_path: Vec = legacy.attribute::>(ATTR_EDITOR_LAYER_PATH, source).cloned().unwrap_or_default(); let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0; @@ -235,6 +240,7 @@ where Attr(transform), Attr(fill), Attr(stroke), + Attr(appearance), Attr(legacy.attribute_cloned_or_default(core_types::ATTR_BLEND_MODE, source)), Attr(legacy.attribute_cloned_or(core_types::ATTR_OPACITY, source, 1.)), Attr(legacy.attribute_cloned_or(core_types::ATTR_OPACITY_FILL, source, 1.)), @@ -268,6 +274,7 @@ fn mirror<'e>( Attr<'e, TransformAttr>, Attr<'e, graphic_types::markers::Fill>, Attr<'e, graphic_types::markers::Stroke>, + Attr<'e, graphic_types::markers::Appearance>, Attr<'e, core_types::attribute::BlendMode>, Attr<'e, core_types::attribute::Opacity>, Attr<'e, core_types::attribute::OpacityFill>, @@ -318,6 +325,7 @@ fn mirror_vector<'e>( Attr<'e, TransformAttr>, Attr<'e, graphic_types::markers::Fill>, Attr<'e, graphic_types::markers::Stroke>, + Attr<'e, graphic_types::markers::Appearance>, Attr<'e, core_types::attribute::BlendMode>, Attr<'e, core_types::attribute::Opacity>, Attr<'e, core_types::attribute::OpacityFill>, diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 70cfef21c1..3a17f26f83 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -3,8 +3,9 @@ use core_types::list::{Item, List}; use core_types::uuid::NodeId; use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, BlendMode, Color, Ctx}; use glam::{DAffine2, DVec2}; +use graphic_types::appearance::{Appearance, CoverPlacement, Coverage}; use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, set_paint_attribute, set_paint_attribute_at}; -use graphic_types::markers::{EditorMergedLayers, Fill, Stroke}; +use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers, Fill, Stroke}; use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::vector_types::GradientStops; use graphic_types::vector_types::gradient::{GradientSpreadMethod, GradientType}; @@ -35,6 +36,7 @@ fn boolean_core<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -78,6 +80,10 @@ fn boolean_core<'e>( use core_types::lane::LaneSource; let fill = park_paint(result_vector_list.attr::(0).filter(|paint| is_paint_present(paint)).cloned())?; let stroke = park_paint(result_vector_list.attr::(0).filter(|paint| is_paint_present(paint)).cloned())?; + let appearance = match result_vector_list.attr::(0).cloned() { + Some(appearance) => Some(&*arena.alloc_sized_keyed(appearance, 0).ok_or_else(exhausted)?.0), + None => None, + }; let layer_path: Vec = result_vector_list.attribute::>(ATTR_EDITOR_LAYER_PATH, 0).cloned().unwrap_or_default(); let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0; // Snapshot the input layers so the renderer can recurse into them for @@ -89,6 +95,7 @@ fn boolean_core<'e>( Attr(result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)), Attr(fill), Attr(stroke), + Attr(appearance), Attr(result_vector_list.attribute_cloned_or_default(ATTR_BLEND_MODE, 0)), Attr(result_vector_list.attribute_cloned_or(ATTR_OPACITY, 0, 1.)), Attr(result_vector_list.attribute_cloned_or(ATTR_OPACITY_FILL, 0, 1.)), @@ -117,6 +124,7 @@ fn boolean_operation<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -144,6 +152,7 @@ fn boolean_operation_vector<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -301,15 +310,22 @@ fn raster_stand_in_rows(image: &S, parent_trans .with_attribute(ATTR_CLIPPING_MASK, clip) .with_attribute(ATTR_EDITOR_LAYER_PATH, layer); set_paint_attribute(item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); + item.set_attribute(graphic_types::ATTR_APPEARANCE, fill_appearance(List::new_from_element(Graphic::Color(Color::BLACK)))); item }) .collect() } +/// The single-fill appearance a built row paints with, beside its legacy fill marker. +fn fill_appearance(paint: List>) -> Appearance { + Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint)) +} + /// A color row: an empty vector carrying the color as its fill paint over the /// lane's attributes. fn color_paint_row(color: Color, mut attributes: core_types::list::ItemAttributeValues) -> Item { set_paint_attribute(&mut attributes, ATTR_FILL, List::new_from_element(color)); + attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(List::new_from_element(Graphic::Color(color)))); let mut element = Vector::default(); element.set_stroke_transform(DAffine2::IDENTITY); @@ -330,7 +346,8 @@ fn gradient_paint_row(stops: GradientStops, mut attributes: core_types::list::It if let Some(spread_method) = attributes.remove::(ATTR_SPREAD_METHOD) { gradient_paint.set_attribute(ATTR_SPREAD_METHOD, 0, spread_method); } - attributes.insert(ATTR_FILL, Some(gradient_paint)); + attributes.insert(ATTR_FILL, Some(gradient_paint.clone())); + attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(gradient_paint)); let mut element = Vector::default(); element.set_stroke_transform(DAffine2::IDENTITY); @@ -368,6 +385,7 @@ fn push_leaf_vector_row(out: &mut List, level: GraphicLevel<'_>, index: } } } + stamp_inherited_appearance(out, out_index, reach.appearance); let current: DAffine2 = out.attribute_cloned_or_default(ATTR_TRANSFORM, out_index); out.set_attribute(ATTR_TRANSFORM, out_index, ancestors * current); } @@ -384,11 +402,21 @@ fn push_vector_rows(out: &mut List, rows: &List, composed: DAffi } } } + stamp_inherited_appearance(out, index, reach.appearance); let current: DAffine2 = out.attribute_cloned_or_default(ATTR_TRANSFORM, index); out.set_attribute(ATTR_TRANSFORM, index, composed * current); } } +/// The cascade's resolved appearance lands on a row whose own is undeclared, since a declared row wins wholesale. +fn stamp_inherited_appearance(out: &mut List, index: usize, inherited: Option<&Appearance>) { + if let Some(appearance) = inherited + && out.attribute::(graphic_types::ATTR_APPEARANCE, index).and_then(Appearance::declared).is_none() + { + out.set_attribute(graphic_types::ATTR_APPEARANCE, index, appearance.clone()); + } +} + fn push_union(out: &mut List, flattened: List) { for row in boolean_operation_on_vector_list(&flattened, BooleanOperation::Union).into_iter() { out.push(row); diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index fc20f114a9..b881d6bfb0 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -17,7 +17,7 @@ use core_types::transform::Transform; use core_types::uuid::NodeId; use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex}; use glam::{DAffine2, DMat2, DVec2}; -use graphic_types::appearance::{Appearance, CoverPlacement, Coverage}; +use graphic_types::appearance::{Appearance, Cover, CoverPlacement, Coverage}; use graphic_types::graphic::{bake_paint_transforms, has_paint, is_paint_present, set_paint_attribute_at}; use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers, Fill, Stroke as StrokeAttr}; use graphic_types::raster_types::{CPU, GPU, Raster}; @@ -101,7 +101,17 @@ fn assign_colors<'e>( /// The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once. #[widget(ParsedWidgetOverride::Custom = "assign_colors_repeat_every")] repeat_every: u32, -) -> Result, Attr<'e, Fill>, Attr<'e, StrokeAttr>, Attr<'e, EditorLayerPath>)>, Interrupt> { +) -> Result< + IList<( + Vector, + Attr<'e, TransformAttr>, + Attr<'e, Fill>, + Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, + Attr<'e, EditorLayerPath>, + )>, + Interrupt, +> { let lane = ctx.index() as usize; if lane >= content.len() { return Err(GraphError::past_end().into()); @@ -110,11 +120,14 @@ fn assign_colors<'e>( let park_existing = |paint: Option<&List>>| -> Result>, Interrupt> { paint.map(|paint| park_paint(ctx.arena(), paint.clone())).transpose() }; let existing_fill = park_existing(content.lane(lane).attr::())?; let existing_stroke = park_existing(content.lane(lane).attr::())?; + let existing_appearance = content.lane(lane).attr::().cloned(); + let park_appearance_attr = |appearance: Option| -> Result, Interrupt> { appearance.map(|appearance| park_appearance(ctx.arena(), appearance)).transpose() }; let carried = carried_lane_attrs(ctx.arena(), *content.lane(lane))?; let (transform, layer_path) = carried; if gradient.is_empty() { - return Ok((element, transform, Attr(existing_fill), Attr(existing_stroke), layer_path)); + let parked_appearance = park_appearance_attr(existing_appearance)?; + return Ok((element, transform, Attr(existing_fill), Attr(existing_stroke), Attr(parked_appearance), layer_path)); } let gradient_element = gradient.element_ref(0); let reversed; @@ -138,7 +151,19 @@ fn assign_colors<'e>( true => Some(parked), false => existing_stroke, }; - Ok((element, transform, Attr(fill_attr), Attr(stroke_attr), layer_path)) + + // The same recolor lands on the appearance's coverage paints + let mut appearance = existing_appearance.unwrap_or_default(); + let paint_cell = Graphic::Graphic(parked.clone()); + if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) { + appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Above); + } + if stroke && element.stroke.is_some() && !appearance.set_paint_of(Cover::Stroke, paint_cell.clone()) { + appearance.replace_or_insert(Coverage::new_stroke(&element.stroke.clone().unwrap_or_default()), paint_cell, CoverPlacement::Above); + } + let parked_appearance = park_appearance_attr(Some(appearance))?; + + Ok((element, transform, Attr(fill_attr), Attr(stroke_attr), Attr(parked_appearance), layer_path)) } #[allow(clippy::too_many_arguments)] @@ -229,15 +254,26 @@ fn assign_colors_graphic<'e>( let element = match rows { Some(mut rows) => { for row in 0..rows.len() { - let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some()); + let row_stroke = rows.element(row).and_then(|vector| vector.stroke.clone()); let color = assign_color_at(gradient_element, position + row, length, randomize, seed, repeat_every); let paint = List::new_from_element(color).into_graphic_list(); if fill { set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone()); } - if stroke && has_stroke { + if stroke && row_stroke.is_some() { set_paint_attribute_at(&mut rows, row, ATTR_STROKE, paint.clone()); } + + // The same recolor lands on the row's appearance coverage paints + let mut appearance = rows.attribute_cloned_or_default::(graphic_types::ATTR_APPEARANCE, row); + let paint_cell = Graphic::Graphic(paint.clone()); + if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) { + appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Above); + } + if stroke && row_stroke.is_some() && !appearance.set_paint_of(Cover::Stroke, paint_cell.clone()) { + appearance.replace_or_insert(Coverage::new_stroke(&row_stroke.unwrap_or_default()), paint_cell, CoverPlacement::Above); + } + rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance); } let content = core_types::record::GroupItem::from_list(rows, ctx.arena()).ok_or_else(|| Interrupt::from(GraphError::new("the arena is exhausted")))?; Graphic::Group(core_types::record::Group { row: None, content }) @@ -287,6 +323,15 @@ fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List(arena: &'e core_types::arena::Arena, attributes: &ItemAttributeValues) -> Result, Interrupt> { + attributes + .get::(graphic_types::ATTR_APPEARANCE) + .cloned() + .map(|appearance| park_appearance(arena, appearance)) + .transpose() +} + /// Keyed, as [`park_paint`] is, so a group-free appearance's promote moves this header. fn park_appearance<'e>(arena: &'e core_types::arena::Arena, appearance: Appearance) -> Result<&'e Appearance, Interrupt> { let (parked, _) = arena.alloc_sized_keyed(appearance, 0).ok_or(GraphError { @@ -297,11 +342,12 @@ fn park_appearance<'e>(arena: &'e core_types::arena::Arena, appearance: Appearan } /// Appends one coverage to the content's appearance following the painter's algorithm: -/// the most downstream paint node in the chain paints on top. The coverage's paint is the -/// canonical paint list carried as one graphic cell, the input lane's own envelope dropped. -fn stamped_appearance(content_appearance: Option<&Appearance>, coverage: Coverage, paint: &List>) -> Appearance { +/// the most downstream paint node in the chain paints on top unless it asks for the +/// below placement. The coverage's paint is the canonical paint list carried as one +/// graphic cell, the input lane's own envelope dropped. +fn stamped_appearance(content_appearance: Option<&Appearance>, coverage: Coverage, paint: &List>, placement: CoverPlacement) -> Appearance { let mut appearance = content_appearance.cloned().unwrap_or_default(); - appearance.replace_or_insert(coverage, Graphic::Graphic(paint.clone()), CoverPlacement::Above); + appearance.replace_or_insert(coverage, Graphic::Graphic(paint.clone()), placement); appearance } @@ -363,7 +409,7 @@ fn fill<'e>( ) -> Result<(Vector, Attr<'e, Fill>, Attr<'e, AppearanceMarker>), Interrupt> { let mut paint = paint_table(fill); default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _has_transform.0.then_some(_transform)); - let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint); + let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above); let parked = park_paint(ctx.arena(), paint)?; let parked_appearance = park_appearance(ctx.arena(), appearance)?; Ok((element, Attr(Some(parked)), Attr(Some(parked_appearance)))) @@ -390,7 +436,7 @@ fn fill_graphic_leveled<'e>( }; let mut paint = paint_table(fill); default_gradient_paint(&mut paint, bounds, _gradient_type, _spread_method, _has_transform.0.then_some(_transform)); - let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint); + let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above); let parked = park_paint(ctx.arena(), paint)?; let parked_appearance = park_appearance(ctx.arena(), appearance)?; Ok((element, Attr(Some(parked)), Attr(Some(parked_appearance)))) @@ -439,11 +485,10 @@ fn stroke<'e>( paint_order, }; - // 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. + // The coverage records the stroke's authoring space, so the item transform is composed in, translation + // included so the render consumers see the exact legacy stroke space. let mut coverage_stroke = stroke.clone(); coverage_stroke.transform *= *content_transform; - coverage_stroke.transform.translation = DVec2::ZERO; stroke.transform *= *content_transform; @@ -451,7 +496,12 @@ fn stroke<'e>( element.stroke = Some(stroke); let paint = paint_table(paint); - let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint); + // The coverage order carries the paint order: a below stroke lands before the fill in the list + let placement = match paint_order { + PaintOrder::StrokeAbove => CoverPlacement::Above, + PaintOrder::StrokeBelow => CoverPlacement::Below, + }; + let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, placement); let parked = park_paint(ctx.arena(), paint)?; let parked_appearance = park_appearance(ctx.arena(), appearance)?; Ok((element, Attr(*content_transform), Attr(Some(parked)), Attr(Some(parked_appearance)))) @@ -506,11 +556,9 @@ fn stroke_graphic_leveled<'e>( paint_order, }; - // The coverage records the stroke's authoring space at the lane, composing the lane transform with cleared - // translation, for the same reason as in `stroke` above. + // The coverage records the stroke's authoring space at the lane, composing the lane transform as in `stroke` above. let mut coverage_stroke = stroke.clone(); coverage_stroke.transform *= *content_transform; - coverage_stroke.transform.translation = DVec2::ZERO; let mut element = element; for_each_interior_vector_mut(&mut element, |vector, transform| { @@ -520,7 +568,12 @@ fn stroke_graphic_leveled<'e>( }); let paint = paint_table(paint); - let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint); + // The coverage order carries the paint order: a below stroke lands before the fill in the list + let placement = match paint_order { + PaintOrder::StrokeAbove => CoverPlacement::Above, + PaintOrder::StrokeBelow => CoverPlacement::Below, + }; + let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, placement); let parked = park_paint(ctx.arena(), paint)?; let parked_appearance = park_appearance(ctx.arena(), appearance)?; Ok((element, Attr(*content_transform), Attr(Some(parked)), Attr(Some(parked_appearance)))) @@ -1467,6 +1520,9 @@ fn solidify_rows(flattened: List) -> List { let mut fill_attributes = attributes.clone(); // No stroke remains on the fill row fill_attributes.remove::>>(ATTR_STROKE); + if let Some(appearance) = fill_attributes.get_mut::(graphic_types::ATTR_APPEARANCE) { + appearance.retain_cover(Cover::Fill); + } Item::from_parts(vector, fill_attributes) }); @@ -1474,6 +1530,14 @@ fn solidify_rows(flattened: List) -> List { // Drop the original fill and use the stroke paint to fill the outlined stroke stroke_attributes.remove::>>(ATTR_FILL); stroke_attributes.rename(ATTR_STROKE, ATTR_FILL); + if let Some(appearance) = stroke_attributes.get_mut::(graphic_types::ATTR_APPEARANCE) { + // The outlined stroke is filled with the stroke coverage's paint + let stroke_paint = appearance.first_paint_of(Cover::Stroke).cloned(); + *appearance = Appearance::default(); + if let Some(paint) = stroke_paint { + appearance.replace_or_insert(Coverage::new_fill(), paint, CoverPlacement::Above); + } + } let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes); @@ -1503,6 +1567,7 @@ fn solidify_native_lane<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1563,6 +1628,7 @@ fn emit_legacy_lane<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1593,6 +1659,11 @@ fn emit_legacy_lane<'e>( .and_then(|paint| paint.as_ref()) .map(|paint| park_paint(arena, paint.clone())) .transpose()?; + let appearance = output + .attribute::(graphic_types::ATTR_APPEARANCE, lane) + .cloned() + .map(|appearance| park_appearance(arena, appearance)) + .transpose()?; let layer_path: Vec = output.attribute::>(ATTR_EDITOR_LAYER_PATH, lane).cloned().unwrap_or_default(); let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0; let merged_layers = output @@ -1606,6 +1677,7 @@ fn emit_legacy_lane<'e>( Attr(output.attribute_cloned_or_default(ATTR_TRANSFORM, lane)), Attr(fill), Attr(stroke), + Attr(appearance), Attr(output.attribute_cloned_or_default(ATTR_BLEND_MODE, lane)), Attr(output.attribute_cloned_or(ATTR_OPACITY, lane, 1.)), Attr(output.attribute_cloned_or(ATTR_OPACITY_FILL, lane, 1.)), @@ -1653,6 +1725,7 @@ fn solidify_stroke<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1693,6 +1766,7 @@ fn solidify_stroke_vector<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1765,6 +1839,7 @@ fn separate_subpaths<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1821,6 +1896,7 @@ fn map_points<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -1862,6 +1938,7 @@ fn flatten_path_core<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, EditorLayerPath>, Attr<'e, EditorMergedLayers>, ), @@ -1927,7 +2004,31 @@ fn flatten_path_core<'e>( // editor click-target preservation, as the boolean operation does. let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0; - Ok((output, Attr(DAffine2::IDENTITY), Attr(fill), Attr(stroke), Attr(layer_path.as_slice()), Attr(Some(merged_layers)))) + // The carried paints land on the appearance too, the stroke coverage recording the carried stroke's parameters + let appearance = { + let mut appearance = Appearance::default(); + if let Some(fill_paint) = fill { + appearance.replace_or_insert(Coverage::new_fill(), Graphic::Graphic(fill_paint.clone()), CoverPlacement::Above); + } + if let Some(stroke_paint) = stroke { + let coverage = Coverage::new_stroke(&output.stroke.clone().unwrap_or_default()); + appearance.replace_or_insert(coverage, Graphic::Graphic(stroke_paint.clone()), CoverPlacement::Above); + } + match appearance.declared().is_some() { + true => Some(park_appearance(arena, appearance)?), + false => None, + } + }; + + Ok(( + output, + Attr(DAffine2::IDENTITY), + Attr(fill), + Attr(stroke), + Attr(appearance), + Attr(layer_path.as_slice()), + Attr(Some(merged_layers)), + )) } // 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. @@ -1941,6 +2042,7 @@ pub fn flatten_path<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, EditorLayerPath>, Attr<'e, EditorMergedLayers>, ), @@ -1964,6 +2066,7 @@ pub fn flatten_path_vector<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, EditorLayerPath>, Attr<'e, EditorMergedLayers>, ), @@ -2289,6 +2392,7 @@ fn cut_path<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -3273,11 +3377,19 @@ fn morph_core(flattened: List, snapshot: List>, progres .with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path) .with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content)); + // The lerped paints land on the appearance too, the stroke coverage recording the lerped stroke's parameters + let mut appearance = Appearance::default(); if let Some(fill) = fill_paint { - item.set_attribute(ATTR_FILL, Some(fill)); + item.set_attribute(ATTR_FILL, Some(fill.clone())); + appearance.replace_or_insert(Coverage::new_fill(), Graphic::Graphic(fill), CoverPlacement::Above); } if let Some(stroke) = stroke_paint { - item.set_attribute(ATTR_STROKE, Some(stroke)); + item.set_attribute(ATTR_STROKE, Some(stroke.clone())); + let coverage = Coverage::new_stroke(&item.element().stroke.clone().unwrap_or_default()); + appearance.replace_or_insert(coverage, Graphic::Graphic(stroke), CoverPlacement::Above); + } + if appearance.declared().is_some() { + item.set_attribute(graphic_types::ATTR_APPEARANCE, appearance); } List::new_from_item(item) @@ -3300,6 +3412,7 @@ fn morph_lane<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -3340,6 +3453,7 @@ fn morph<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>, @@ -3372,6 +3486,7 @@ fn morph_vector<'e>( Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, StrokeAttr>, + Attr<'e, AppearanceMarker>, Attr<'e, BlendModeAttr>, Attr<'e, Opacity>, Attr<'e, OpacityFill>,