Retire the stroke paint order input into fill and stroke node order

The Stroke node no longer takes a paint order value: stroke-below is now
the Stroke node sitting upstream of the Fill node, and the editor's
paint order control rewires the two nodes through set_stroke_paint_order.
Documents with the retired input migrate to the nine-input shape, their
stored order replayed as that topology rewrite. The Stroke struct drops
its paint_order field; renderers read stroke_below from the resolved
appearance alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Kobert
2026-09-09 22:44:18 +00:00
parent 46e0a4f2f5
commit f726e712bc
15 changed files with 164 additions and 89 deletions

View File

@@ -213,7 +213,7 @@ impl RenderExt for Stroke {
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
let paint_order = render_params.stroke_below.then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = String::new();

View File

@@ -31,7 +31,7 @@ use graphic_types::vector_types::gradient::{GradientStops, GradientType};
use graphic_types::vector_types::markers::{GradientType as GradientTypeAttr, SpreadMethod};
use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{ATTR_FILL, Artboard, Graphic, Vector};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
@@ -302,7 +302,7 @@ pub struct RenderParams {
/// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks
pub alignment_parent_transform: Option<DAffine2>,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub stroke_below: bool,
/// Are we rendering for a pattern content
pub inside_pattern: bool,
pub artboard_background: Option<Color>,
@@ -1340,11 +1340,7 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appe
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(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 resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
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
@@ -1387,7 +1383,8 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appe
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 = element_stroke.map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
@@ -1474,7 +1471,7 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appe
let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = override_paint_order;
render_params.stroke_below = override_paint_order || wants_stroke_below;
let stroke_shape_attribute = element_stroke
.map(|stroke| {
@@ -1559,8 +1556,6 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(
render_params: &RenderParams,
) {
for index in 0..source.lane_count() {
use graphic_types::vector_types::vector;
let Some(element) = source.element(index) else { continue };
let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
@@ -1570,15 +1565,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(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 resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
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));
@@ -1641,7 +1628,8 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(
}
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list else { return };
@@ -1827,7 +1815,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(
Stroke,
}
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
let order = match stroke.is_some() && wants_stroke_below {
true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
};

View File

@@ -230,8 +230,6 @@ pub struct Stroke {
pub align: StrokeAlign,
#[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))]
pub transform: DAffine2,
#[cfg_attr(feature = "serde", serde(default))]
pub paint_order: PaintOrder,
}
impl Stroke {
@@ -245,7 +243,6 @@ impl Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -282,7 +279,6 @@ impl Stroke {
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
trs * skew
},
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
}
}
@@ -410,7 +406,6 @@ impl Default for Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::default(),
}
}
}

View File

@@ -43,7 +43,7 @@ pub struct CacheKey {
pub for_mask: bool,
pub thumbnail: bool,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub stroke_below: bool,
pub animation_time_ms: i64,
pub real_time_ms: i64,
pub pointer: [u8; 16],
@@ -60,7 +60,7 @@ impl CacheKey {
for_mask: bool,
thumbnail: bool,
aligned_strokes: bool,
override_paint_order: bool,
stroke_below: bool,
animation_time: f64,
real_time: f64,
pointer: Option<DVec2>,
@@ -85,7 +85,7 @@ impl CacheKey {
for_mask,
thumbnail,
aligned_strokes,
override_paint_order,
stroke_below,
animation_time_ms: (animation_time * 1000.).round() as i64,
real_time_ms: (real_time * 1000.).round() as i64,
pointer: pointer_bytes,
@@ -360,7 +360,7 @@ pub fn render_output_cache(
render_params.for_mask,
render_params.thumbnail,
render_params.aligned_strokes,
render_params.override_paint_order,
render_params.stroke_below,
ctx.try_animation_time().unwrap_or(0.),
ctx.try_real_time().unwrap_or(0.),
ctx.try_pointer_position(),

View File

@@ -3,7 +3,7 @@ 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::appearance::{Appearance, 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::{Appearance as AppearanceMarker, EditorMergedLayers, Fill, Stroke};
use graphic_types::raster_types::{CPU, GPU, Raster};

View File

@@ -38,7 +38,7 @@ use vector_types::vector::misc::{
CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups,
bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
};
use vector_types::vector::style::{GradientStops, HasTransform, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{GradientStops, HasTransform, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD};
use vector_types::{GradientSpreadMethod, GradientType};
@@ -323,15 +323,6 @@ fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List<Graphic<'stat
Ok(parked)
}
/// Parks a row's carried appearance, absent rows staying absent.
fn park_row_appearance<'e>(arena: &'e core_types::arena::Arena, attributes: &ItemAttributeValues) -> Result<Option<&'e Appearance>, Interrupt> {
attributes
.get::<Appearance>(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 {
@@ -464,8 +455,6 @@ fn stroke<'e>(
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
#[default(4.)]
miter_limit: f64,
/// 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.
dash_lengths: IList<f64>,
/// The phase offset distance from the starting point of the dash pattern.
@@ -482,7 +471,6 @@ fn stroke<'e>(
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
// The coverage records the stroke's authoring space, so the item transform is composed in, translation
@@ -496,12 +484,9 @@ fn stroke<'e>(
element.stroke = Some(stroke);
let paint = paint_table(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);
// The paint order is the coverage row order: appending above follows the painter's algorithm, and a
// below stroke is expressed by the chain running the stroke node before the fill
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
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))))
@@ -539,7 +524,6 @@ fn stroke_graphic_leveled<'e>(
cap: StrokeCap,
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
paint_order: PaintOrder,
dash_lengths: IList<f64>,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, StrokeAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
@@ -553,7 +537,6 @@ fn stroke_graphic_leveled<'e>(
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
// The coverage records the stroke's authoring space at the lane, composing the lane transform as in `stroke` above.
@@ -568,12 +551,9 @@ fn stroke_graphic_leveled<'e>(
});
let paint = paint_table(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);
// The paint order is the coverage row order: appending above follows the painter's algorithm, and a
// below stroke is expressed by the chain running the stroke node before the fill
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
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))))
@@ -1485,7 +1465,11 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths;
let miter_limit = stroke.join_miter_limit;
let paint_order = stroke.paint_order;
// The paint order rides the row's coverage order
let stroke_below = attributes
.get::<Appearance>(graphic_types::ATTR_APPEARANCE)
.map(Appearance::fill_and_stroke)
.is_some_and(|resolved| resolved.stroke_below);
let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap)
@@ -1541,10 +1525,10 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes);
// Ordering based on the paint order. The first item in the `List` is rendered below the second.
match paint_order {
PaintOrder::StrokeAbove => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
PaintOrder::StrokeBelow => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
// Ordering based on the coverage order. The first item in the `List` is rendered below the second.
match stroke_below {
false => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
true => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
}
})
.collect();