Retire the Vector stroke field into the appearance coverage

Stroke parameters live only on the appearance's stroke coverage now.
Vector loses its stroke field, transform normalization, and concat
stroke adoption; solidify, morph, combine, and the recolor read the
coverage instead, with morph gaining the cover-paired appearance lerp.
The stroke-inclusive bounds take the stroke as a parameter, the plain
vector bounds ignore include_stroke, the editor's metadata channel
carries one resolved appearance snapshot per layer, and the data
panel's Vector table drops its stroke properties tab for a handles tab.
Legacy vector payloads parse their stroke solely to validate the shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Kobert
2026-09-10 00:37:10 +00:00
parent d278fd666d
commit ff074b2a1c
14 changed files with 168 additions and 355 deletions

View File

@@ -419,9 +419,8 @@ impl<'e> Graphic<'e> {
pub fn is_fully_transparent(&self) -> bool {
match self {
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked
// vector is invisible on its own.
Graphic::Vector(vector) => vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()),
// A bare vector leaf carries no paint or stroke of its own, so it is invisible on its own
Graphic::Vector(_) => true,
Graphic::Color(color) => color.a() == 0.,
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,

View File

@@ -93,10 +93,11 @@ pub mod migrations {
}
/// The legacy `fill` field is intentionally omitted because vector payload migration only
/// recovers editable vector data. The fill/stroke paints are migrated from the the node inputs.
/// recovers editable vector data. The stroke parses solely to validate the legacy shape.
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>,
}
@@ -104,6 +105,7 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct VectorData {
#[allow(dead_code)]
pub style: PathStyle,
pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain,
@@ -136,7 +138,6 @@ pub mod migrations {
Ok(match VectorFormat::deserialize(deserializer)? {
VectorFormat::OldVectorData(old) => Some(Vector {
stroke: old.style.stroke,
colinear_manipulators: old.colinear_manipulators,
point_domain: old.point_domain,
segment_domain: old.segment_domain,
@@ -153,9 +154,12 @@ pub mod migrations {
use vector_types::vector::style::Stroke;
#[test]
fn preserves_stroke_from_old_vector_data_style() {
fn recovers_geometry_from_old_vector_data_style() {
use core_types::ops::FromAnchorPosition;
let old_vector = legacy::VectorData {
style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) },
point_domain: Vector::from_anchor_position(glam::DVec2::new(3., 4.)).point_domain,
..Default::default()
};
@@ -168,22 +172,21 @@ pub mod migrations {
.as_object_mut()
.unwrap()
.insert("fill".into(), serde_json::to_value(legacy::Fill::default()).unwrap());
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().expect("the legacy shape parses into a vector");
assert_eq!(migrated.stroke.unwrap().weight, 12.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)], "the geometry survives alongside the discarded style");
}
#[test]
fn preserves_stroke_from_current_vector_data() {
let vector = Vector {
stroke: Some(Stroke::new(12.)),
..Default::default()
};
fn recovers_geometry_from_current_vector_data() {
use core_types::ops::FromAnchorPosition;
let vector = Vector::from_anchor_position(glam::DVec2::new(3., 4.));
let value = serde_json::to_value(&vector).unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)]);
}
}
}

View File

@@ -540,12 +540,9 @@ pub struct RenderMetadata {
pub text_frames: HashMap<NodeId, DAffine2>,
pub clip_targets: HashSet<NodeId>,
pub vector_data: HashMap<NodeId, Arc<Vector>>,
/// Per-layer fill paint snapshot from the resolved appearance, exposed so message handlers can read it.
/// Per-layer resolved appearance snapshot, exposed so message handlers can read the paint.
#[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
/// Per-layer stroke paint snapshot from the resolved appearance, exposed so message handlers can read it.
#[cfg_attr(feature = "serde", serde(skip))]
pub stroke_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
pub backgrounds: Vec<Background>,
}
@@ -569,8 +566,7 @@ impl RenderMetadata {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
appearance_attributes,
backgrounds,
} = self;
upstream_footprints.extend(other.upstream_footprints.iter());
@@ -581,8 +577,7 @@ impl RenderMetadata {
text_frames.extend(other.text_frames.iter());
clip_targets.extend(other.clip_targets.iter());
vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone())));
fill_attributes.extend(other.fill_attributes.iter().map(|(id, data)| (*id, data.clone())));
stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone())));
appearance_attributes.extend(other.appearance_attributes.iter().map(|(id, data)| (*id, data.clone())));
// TODO: Find a better non O(n^2) way to merge backgrounds
for background in &other.backgrounds {
@@ -1352,7 +1347,7 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appe
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY, element_stroke).unwrap_or(layer_bounds);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
@@ -1404,8 +1399,7 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appe
let push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone();
cloned_vector.stroke = None;
let cloned_vector = vector.clone();
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
@@ -1759,8 +1753,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(
}
_ => {
if use_layer {
let mut cloned_element = element.clone();
cloned_element.stroke = None;
let cloned_element = element.clone();
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior.
// The outer opacity/blend layer (above) handles the user-set opacity.
@@ -1894,11 +1887,8 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(
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) = resolved.fill_paint.and_then(paint_cell_rows) {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone()));
}
if let Some(stroke_graphic) = resolved.stroke_paint.and_then(paint_cell_rows) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.clone()));
if let Some(appearance) = appearance {
metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone()));
}
}
@@ -3212,7 +3202,14 @@ mod group_walk_tests {
assert!(native.local_transforms.contains_key(&caller));
assert!(native.upstream_footprints.contains_key(&caller));
assert_eq!(native.vector_data.get(&caller).map(|vector| vector.as_ref()), Some(&vectors[0]));
assert!(native.fill_attributes.get(&caller).is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_)))));
assert!(
native
.appearance_attributes
.get(&caller)
.and_then(|appearance| appearance.first_paint_of(graphic_types::appearance::Cover::Fill))
.and_then(paint_cell_rows)
.is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_))))
);
}
#[test]

View File

@@ -8,7 +8,6 @@ use crate::vector::vector_modification::VectorExt;
use core::borrow::Borrow;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use dyn_any::StaticType;
use glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
@@ -18,8 +17,6 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Vector {
pub stroke: Option<Stroke>,
/// A list of all manipulator groups (referenced in `subpaths`) that have colinear handles (where they're locked at 180° angles from one another).
/// This gets read in `graph_operation_message_handler.rs` by calling `inputs.as_mut_slice()` (search for the string `"Shape does not have both `subpath` and `colinear_manipulators` inputs"` to find it).
pub colinear_manipulators: Vec<[HandleId; 2]>,
@@ -35,7 +32,6 @@ unsafe impl StaticType for Vector {
impl Default for Vector {
fn default() -> Self {
Self {
stroke: Some(Stroke::new(0.)),
colinear_manipulators: Vec::new(),
point_domain: PointDomain::new(),
segment_domain: SegmentDomain::new(),
@@ -49,7 +45,6 @@ impl graphene_hash::CacheHash for Vector {
self.point_domain.cache_hash(state);
self.segment_domain.cache_hash(state);
self.region_domain.cache_hash(state);
self.stroke.cache_hash(state);
self.colinear_manipulators.cache_hash(state);
}
}
@@ -239,10 +234,10 @@ impl Vector {
/// identity (`Inside` = 0, `Outside` = 2×weight): the renderer masks half of a centered double-width
/// stroke, so its AABB matches the unmasked centered stroke's. For open paths the renderer always
/// draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here.
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2, stroke: Option<&Stroke>) -> Option<[DVec2; 2]> {
let path_bounds = self.bounding_box_with_transform(transform);
let Some(stroke) = self.stroke.as_ref() else { return path_bounds };
let Some(stroke) = stroke else { return path_bounds };
// Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall
// back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync.
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed());
@@ -530,40 +525,14 @@ impl Vector {
self.segment_domain.concat(&additional.segment_domain, transform_of_additional, &id_map);
self.region_domain.concat(&additional.region_domain, transform_of_additional, &id_map);
// TODO: properly deal with fills such as gradients
self.stroke = additional.stroke.clone();
self.colinear_manipulators.extend(additional.colinear_manipulators.iter().copied());
}
pub fn set_stroke_transform(&mut self, transform: DAffine2) {
if let Some(stroke) = &mut self.stroke {
stroke.transform = transform;
}
}
}
impl BoundingBox for Vector {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
if !include_stroke {
// Just use the path bounds without stroke
return match self.bounding_box_with_transform(transform) {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
};
}
// Include stroke by adding offset based on stroke width
let stroke = self.stroke.clone();
let stroke_width = stroke.as_ref().map(|s| s.weight()).unwrap_or_default();
let miter_limit = stroke.as_ref().map(|s| s.join_miter_limit).unwrap_or(1.);
let scale = transform.scale_magnitudes();
// Use the full line width to account for different styles of stroke caps
let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
match self.bounding_box_with_transform(transform) {
Some([a, b]) => RenderBoundingBox::Rectangle([a - offset, b + offset]),
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}