mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 02:48:12 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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.)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user