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
co-authored by Claude Fable 5
parent d278fd666d
commit ff074b2a1c
14 changed files with 168 additions and 355 deletions
@@ -37,8 +37,8 @@ pub enum PathStep {
#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)] #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum VectorTableTab { pub enum VectorTableTab {
#[default] #[default]
Properties,
Points, Points,
Segments, Segments,
Regions, Regions,
Handles,
} }
@@ -376,7 +376,7 @@ impl TableItemLayout for Vector {
) )
} }
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> { fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
let table_tab_entries = [VectorTableTab::Properties, VectorTableTab::Points, VectorTableTab::Segments, VectorTableTab::Regions] let table_tab_entries = [VectorTableTab::Points, VectorTableTab::Segments, VectorTableTab::Regions, VectorTableTab::Handles]
.into_iter() .into_iter()
.map(|tab| { .map(|tab| {
RadioEntryData::new(format!("{tab:?}")) RadioEntryData::new(format!("{tab:?}"))
@@ -388,57 +388,6 @@ impl TableItemLayout for Vector {
let mut table_rows = Vec::new(); let mut table_rows = Vec::new();
match data.vector_table_tab { match data.vector_table_tab {
VectorTableTab::Properties => {
table_rows.push(column_headings(&["property", "value"]));
if let Some(stroke) = self.stroke.as_ref() {
table_rows.push(vec![
TextLabel::new("Stroke Weight").narrow(true).widget_instance(),
TextLabel::new(format!("{} px", stroke.weight)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Dash Lengths").narrow(true).widget_instance(),
TextLabel::new(if stroke.dash_lengths.is_empty() {
"-".to_string()
} else {
format!("[{}]", stroke.dash_lengths.iter().map(|x| format!("{x} px")).collect::<Vec<_>>().join(", "))
})
.narrow(true)
.widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Dash Offset").narrow(true).widget_instance(),
TextLabel::new(format!("{}", stroke.dash_offset)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Cap").narrow(true).widget_instance(),
TextLabel::new(stroke.cap.to_string()).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Join").narrow(true).widget_instance(),
TextLabel::new(stroke.join.to_string()).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Join Miter Limit").narrow(true).widget_instance(),
TextLabel::new(format!("{}", stroke.join_miter_limit)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Align").narrow(true).widget_instance(),
TextLabel::new(stroke.align.to_string()).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Transform").narrow(true).widget_instance(),
TextLabel::new(format_transform_matrix(stroke.transform)).narrow(true).widget_instance(),
]);
}
let colinear = self.colinear_manipulators.iter().map(|[a, b]| format!("[{a} / {b}]")).collect::<Vec<_>>().join(", ");
let colinear = if colinear.is_empty() { "-".to_string() } else { colinear };
table_rows.push(vec![
TextLabel::new("Colinear Handle IDs").narrow(true).widget_instance(),
TextLabel::new(colinear).narrow(true).widget_instance(),
]);
}
VectorTableTab::Points => { VectorTableTab::Points => {
table_rows.push(column_headings(&["", "position"])); table_rows.push(column_headings(&["", "position"]));
table_rows.extend(self.point_domain.iter().map(|(id, position)| { table_rows.extend(self.point_domain.iter().map(|(id, position)| {
@@ -469,6 +418,16 @@ impl TableItemLayout for Vector {
] ]
})); }));
} }
VectorTableTab::Handles => {
table_rows.push(column_headings(&["", "colinear_manipulators[0]", "colinear_manipulators[1]"]));
table_rows.extend(self.colinear_manipulators.iter().enumerate().map(|(index, [a, b])| {
vec![
TextLabel::new(format!("{index}")).narrow(true).widget_instance(),
TextLabel::new(format!("{a}")).narrow(true).widget_instance(),
TextLabel::new(format!("{b}")).narrow(true).widget_instance(),
]
}));
}
} }
vec![LayoutGroup::row(table_tabs), LayoutGroup::table(table_rows, false)] vec![LayoutGroup::row(table_tabs), LayoutGroup::table(table_rows, false)]
@@ -11,9 +11,8 @@ use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use glam::{DAffine2, IVec2}; use glam::{DAffine2, IVec2};
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::Appearance;
use graphene_std::Color; use graphene_std::Color;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::raster::BlendMode; use graphene_std::raster::BlendMode;
use graphene_std::raster::Image; use graphene_std::raster::Image;
use graphene_std::transform::Footprint; use graphene_std::transform::Footprint;
@@ -245,14 +244,10 @@ pub enum DocumentMessage {
vector_data: HashMap<NodeId, Arc<Vector>>, vector_data: HashMap<NodeId, Arc<Vector>>,
}, },
// `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it. // `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it.
// `UpdateFillAttributes` and `UpdateStrokeAttributes` are produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point. // `UpdateAppearanceAttributes` is produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point.
#[serde(skip)] #[serde(skip)]
UpdateFillAttributes { UpdateAppearanceAttributes {
fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>, appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
},
#[serde(skip)]
UpdateStrokeAttributes {
stroke_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
}, },
Undo, Undo,
UngroupSelectedLayers, UngroupSelectedLayers,
@@ -37,7 +37,7 @@ use graph_craft::application_io::wgpu_available;
use graph_craft::descriptor; use graph_craft::descriptor;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork}; use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork};
use graphene_std::graphic::is_paint_present; use graphene_std::Cover;
use graphene_std::math::quad::Quad; use graphene_std::math::quad::Quad;
use graphene_std::path_bool_nodes::boolean_intersect; use graphene_std::path_bool_nodes::boolean_intersect;
use graphene_std::raster::BlendMode; use graphene_std::raster::BlendMode;
@@ -1502,9 +1502,9 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
.collect(); .collect();
self.network_interface.update_vector_data(layer_vector_data); self.network_interface.update_vector_data(layer_vector_data);
} }
DocumentMessage::UpdateFillAttributes { fill_attributes } => { DocumentMessage::UpdateAppearanceAttributes { appearance_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers // Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_fill_attributes = fill_attributes let layer_appearance_attributes = appearance_attributes
.into_iter() .into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| { .filter_map(|(node_id, attrs)| {
@@ -1514,21 +1514,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
}) })
}) })
.collect(); .collect();
self.network_interface.update_fill_attributes(layer_fill_attributes); self.network_interface.update_appearance_attributes(layer_appearance_attributes);
}
DocumentMessage::UpdateStrokeAttributes { stroke_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_stroke_attributes = stroke_attributes
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| {
self.network_interface.is_layer(&node_id, &[]).then(|| {
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
(layer, attrs)
})
})
.collect();
self.network_interface.update_stroke_attributes(layer_stroke_attributes);
} }
DocumentMessage::Undo => { DocumentMessage::Undo => {
if self.network_interface.transaction_status() != TransactionStatus::Finished { if self.network_interface.transaction_status() != TransactionStatus::Finished {
@@ -2763,20 +2749,19 @@ impl DocumentMessageHandler {
let mut resulting_layers: Vec<NodeId> = Vec::new(); let mut resulting_layers: Vec<NodeId> = Vec::new();
for layer in selected_layers { for layer in selected_layers {
let Some(vector_data) = self.network_interface.document_metadata().layer_vector_data.get(&layer) else { if !self.network_interface.document_metadata().layer_vector_data.contains_key(&layer) {
resulting_layers.push(layer.to_node()); resulting_layers.push(layer.to_node());
continue; continue;
}; }
let stroke = vector_data.stroke.as_ref();
let fill_graphic_list = self.network_interface.document_metadata().layer_fill_attributes.get(&layer); let appearance = self.network_interface.document_metadata().layer_appearance_attributes.get(&layer);
let stroke_graphic_list = self.network_interface.document_metadata().layer_stroke_attributes.get(&layer);
let has_fill = fill_graphic_list.is_some_and(|list| is_paint_present(list)); let has_fill = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill));
// `Vector.stroke` captures stroke geometry, even with weight 0 or transparent paint. // A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something
// So stroke visibility must be checked from the stroke paint snapshot, the paint source of truth. let has_stroke = appearance.is_some_and(|appearance| {
let stroke_visible = stroke_graphic_list.is_some_and(|list| list.element(0).is_some_and(|g| !g.is_fully_transparent())); appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke())
let has_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()) && stroke_visible; && appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_fully_transparent())
});
// No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip. // No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip.
if !has_stroke { if !has_stroke {
@@ -6,8 +6,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Flow
use crate::messages::tool::common_functionality::graph_modification_utils; use crate::messages::tool::common_functionality::graph_modification_utils;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
use graphene_std::Graphic; use graphene_std::Appearance;
use graphene_std::list::List;
use graphene_std::math::quad::Quad; use graphene_std::math::quad::Quad;
use graphene_std::subpath; use graphene_std::subpath;
use graphene_std::transform::Footprint; use graphene_std::transform::Footprint;
@@ -41,12 +40,9 @@ pub struct DocumentMetadata {
/// Vector data keyed by layer ID, used as fallback when no Path node exists. /// Vector data keyed by layer ID, used as fallback when no Path node exists.
/// This provides accurate SegmentIds for layers without explicit Path nodes. /// This provides accurate SegmentIds for layers without explicit Path nodes.
pub layer_vector_data: HashMap<LayerNodeIdentifier, Arc<Vector>>, pub layer_vector_data: HashMap<LayerNodeIdentifier, Arc<Vector>>,
/// Per-layer fill paint snapshot, exposed so message handlers can read paint /// Per-layer resolved appearance snapshot, exposed so message handlers can
/// information that lives on the list. /// read the paint and stroke parameters that live on the coverage rows.
pub layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>, pub layer_appearance_attributes: HashMap<LayerNodeIdentifier, Arc<Appearance>>,
/// Per-layer stroke paint snapshot, exposed so message handlers can read
/// stroke paint information that lives on the list.
pub layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>,
/// Transform from document space to viewport space. /// Transform from document space to viewport space.
pub document_to_viewport: DAffine2, pub document_to_viewport: DAffine2,
} }
@@ -233,10 +229,15 @@ impl DocumentMetadata {
/// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based /// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based
/// bounds for non-vector layers (groups, raster, text, color, gradient). /// bounds for non-vector layers (groups, raster, text, color, gradient).
pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> { pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> {
if let Some(vector) = self.layer_vector_data.get(&layer) if let Some(vector) = self.layer_vector_data.get(&layer) {
&& let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer)) let stroke = self
{ .layer_appearance_attributes
return Some(bounds); .get(&layer)
.and_then(|appearance| appearance.first_coverage_of(graphene_std::Cover::Stroke))
.map(graphene_std::Coverage::stroke_params);
if let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer), stroke.as_ref()) {
return Some(bounds);
}
} }
self.bounding_box_document(layer) self.bounding_box_document(layer)
} }
@@ -24,8 +24,7 @@ use graph_craft::Type;
use graph_craft::application_io::resource::ResourceId; use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue; use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork};
use graphene_std::Graphic; use graphene_std::Appearance;
use graphene_std::list::List;
use graphene_std::math::quad::Quad; use graphene_std::math::quad::Quad;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint; use graphene_std::transform::Footprint;
@@ -3439,14 +3438,9 @@ impl NodeNetworkInterface {
self.document_metadata.layer_vector_data = new_layer_vector_data; self.document_metadata.layer_vector_data = new_layer_vector_data;
} }
/// Update the per-layer fill paint snapshot. /// Update the per-layer resolved appearance snapshot.
pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>) { pub fn update_appearance_attributes(&mut self, new_layer_appearance_attributes: HashMap<LayerNodeIdentifier, Arc<Appearance>>) {
self.document_metadata.layer_fill_attributes = new_layer_fill_attributes; self.document_metadata.layer_appearance_attributes = new_layer_appearance_attributes;
}
/// Update the per-layer stroke paint snapshot.
pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>) {
self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes;
} }
} }
+2 -4
View File
@@ -715,8 +715,7 @@ impl NodeGraphExecutor {
text_frames, text_frames,
clip_targets, clip_targets,
vector_data, vector_data,
fill_attributes, appearance_attributes,
stroke_attributes,
backgrounds: _, backgrounds: _,
} = render_output.metadata; } = render_output.metadata;
@@ -731,8 +730,7 @@ impl NodeGraphExecutor {
responses.add(DocumentMessage::UpdateTextFrames { text_frames }); responses.add(DocumentMessage::UpdateTextFrames { text_frames });
responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); responses.add(DocumentMessage::UpdateClipTargets { clip_targets });
responses.add(DocumentMessage::UpdateVectorData { vector_data }); responses.add(DocumentMessage::UpdateVectorData { vector_data });
responses.add(DocumentMessage::UpdateFillAttributes { fill_attributes }); responses.add(DocumentMessage::UpdateAppearanceAttributes { appearance_attributes });
responses.add(DocumentMessage::UpdateStrokeAttributes { stroke_attributes });
responses.add(DocumentMessage::RenderScrollbars); responses.add(DocumentMessage::RenderScrollbars);
responses.add(DocumentMessage::RenderRulers); responses.add(DocumentMessage::RenderRulers);
responses.add(OverlaysMessage::Draw); responses.add(OverlaysMessage::Draw);
@@ -419,9 +419,8 @@ impl<'e> Graphic<'e> {
pub fn is_fully_transparent(&self) -> bool { pub fn is_fully_transparent(&self) -> bool {
match self { match self {
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent), Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked // A bare vector leaf carries no paint or stroke of its own, so it is invisible on its own
// vector is invisible on its own. Graphic::Vector(_) => true,
Graphic::Vector(vector) => vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()),
Graphic::Color(color) => color.a() == 0., Graphic::Color(color) => color.a() == 0.,
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.), Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false, Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
+14 -11
View File
@@ -93,10 +93,11 @@ pub mod migrations {
} }
/// The legacy `fill` field is intentionally omitted because vector payload migration only /// 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)] #[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))] #[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle { pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>, pub stroke: Option<Stroke>,
} }
@@ -104,6 +105,7 @@ pub mod migrations {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))] #[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct VectorData { pub(super) struct VectorData {
#[allow(dead_code)]
pub style: PathStyle, pub style: PathStyle,
pub colinear_manipulators: Vec<[HandleId; 2]>, pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain, pub point_domain: PointDomain,
@@ -136,7 +138,6 @@ pub mod migrations {
Ok(match VectorFormat::deserialize(deserializer)? { Ok(match VectorFormat::deserialize(deserializer)? {
VectorFormat::OldVectorData(old) => Some(Vector { VectorFormat::OldVectorData(old) => Some(Vector {
stroke: old.style.stroke,
colinear_manipulators: old.colinear_manipulators, colinear_manipulators: old.colinear_manipulators,
point_domain: old.point_domain, point_domain: old.point_domain,
segment_domain: old.segment_domain, segment_domain: old.segment_domain,
@@ -153,9 +154,12 @@ pub mod migrations {
use vector_types::vector::style::Stroke; use vector_types::vector::style::Stroke;
#[test] #[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 { let old_vector = legacy::VectorData {
style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) }, style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) },
point_domain: Vector::from_anchor_position(glam::DVec2::new(3., 4.)).point_domain,
..Default::default() ..Default::default()
}; };
@@ -168,22 +172,21 @@ pub mod migrations {
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
.insert("fill".into(), serde_json::to_value(legacy::Fill::default()).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] #[test]
fn preserves_stroke_from_current_vector_data() { fn recovers_geometry_from_current_vector_data() {
let vector = Vector { use core_types::ops::FromAnchorPosition;
stroke: Some(Stroke::new(12.)),
..Default::default() let vector = Vector::from_anchor_position(glam::DVec2::new(3., 4.));
};
let value = serde_json::to_value(&vector).unwrap(); let value = serde_json::to_value(&vector).unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().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.)]);
} }
} }
} }
+17 -20
View File
@@ -540,12 +540,9 @@ pub struct RenderMetadata {
pub text_frames: HashMap<NodeId, DAffine2>, pub text_frames: HashMap<NodeId, DAffine2>,
pub clip_targets: HashSet<NodeId>, pub clip_targets: HashSet<NodeId>,
pub vector_data: HashMap<NodeId, Arc<Vector>>, 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))] #[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>, pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
/// 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 backgrounds: Vec<Background>, pub backgrounds: Vec<Background>,
} }
@@ -569,8 +566,7 @@ impl RenderMetadata {
text_frames, text_frames,
clip_targets, clip_targets,
vector_data, vector_data,
fill_attributes, appearance_attributes,
stroke_attributes,
backgrounds, backgrounds,
} = self; } = self;
upstream_footprints.extend(other.upstream_footprints.iter()); upstream_footprints.extend(other.upstream_footprints.iter());
@@ -581,8 +577,7 @@ impl RenderMetadata {
text_frames.extend(other.text_frames.iter()); text_frames.extend(other.text_frames.iter());
clip_targets.extend(other.clip_targets.iter()); clip_targets.extend(other.clip_targets.iter());
vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone()))); 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()))); appearance_attributes.extend(other.appearance_attributes.iter().map(|(id, data)| (*id, data.clone())));
stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone())));
// TODO: Find a better non O(n^2) way to merge backgrounds // TODO: Find a better non O(n^2) way to merge backgrounds
for background in &other.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 element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default(); let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).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 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]); 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 push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid()); let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone(); let cloned_vector = vector.clone();
cloned_vector.stroke = None;
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior. // 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. // 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 { if use_layer {
let mut cloned_element = element.clone(); let cloned_element = element.clone();
cloned_element.stroke = None;
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior. // 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. // 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) { if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
e.insert(Arc::new(element.clone())); e.insert(Arc::new(element.clone()));
if let Some(fill_graphic) = resolved.fill_paint.and_then(paint_cell_rows) { if let Some(appearance) = appearance {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone())); metadata.appearance_attributes.insert(element_id, Arc::new(appearance.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()));
} }
} }
@@ -3212,7 +3202,14 @@ mod group_walk_tests {
assert!(native.local_transforms.contains_key(&caller)); assert!(native.local_transforms.contains_key(&caller));
assert!(native.upstream_footprints.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_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] #[test]
@@ -8,7 +8,6 @@ use crate::vector::vector_modification::VectorExt;
use core::borrow::Borrow; use core::borrow::Borrow;
use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity; use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use dyn_any::StaticType; use dyn_any::StaticType;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape}; use kurbo::{Affine, BezPath, Rect, Shape};
@@ -18,8 +17,6 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Vector { 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). /// 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). /// 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]>, pub colinear_manipulators: Vec<[HandleId; 2]>,
@@ -35,7 +32,6 @@ unsafe impl StaticType for Vector {
impl Default for Vector { impl Default for Vector {
fn default() -> Self { fn default() -> Self {
Self { Self {
stroke: Some(Stroke::new(0.)),
colinear_manipulators: Vec::new(), colinear_manipulators: Vec::new(),
point_domain: PointDomain::new(), point_domain: PointDomain::new(),
segment_domain: SegmentDomain::new(), segment_domain: SegmentDomain::new(),
@@ -49,7 +45,6 @@ impl graphene_hash::CacheHash for Vector {
self.point_domain.cache_hash(state); self.point_domain.cache_hash(state);
self.segment_domain.cache_hash(state); self.segment_domain.cache_hash(state);
self.region_domain.cache_hash(state); self.region_domain.cache_hash(state);
self.stroke.cache_hash(state);
self.colinear_manipulators.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 /// 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 /// 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. /// 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 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 // 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. // 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()); 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.segment_domain.concat(&additional.segment_domain, transform_of_additional, &id_map);
self.region_domain.concat(&additional.region_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()); 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 { impl BoundingBox for Vector {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { 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);
match self.bounding_box_with_transform(transform) { match self.bounding_box_with_transform(transform) {
Some([a, b]) => RenderBoundingBox::Rectangle([a - offset, b + offset]), Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None, None => RenderBoundingBox::None,
} }
} }
+1 -1
View File
@@ -12,7 +12,7 @@ pub use graphene_application_io as application_io;
pub use graphene_core; pub use graphene_core;
pub use graphene_core::debug; pub use graphene_core::debug;
pub use graphic_nodes; pub use graphic_nodes;
pub use graphic_types::{Artboard, Graphic, Vector}; pub use graphic_types::{Appearance, Artboard, Cover, Coverage, Graphic, Vector};
pub use math_nodes; pub use math_nodes;
pub use path_bool_nodes; pub use path_bool_nodes;
pub use raster_nodes; pub use raster_nodes;
+3 -15
View File
@@ -54,7 +54,6 @@ fn boolean_core<'e>(
let result_vector = result_vector_list.element_mut(0).unwrap(); let result_vector = result_vector_list.element_mut(0).unwrap();
Vector::transform(result_vector, transform); Vector::transform(result_vector, transform);
result_vector.set_stroke_transform(DAffine2::IDENTITY);
// Clean up the boolean operation result by merging duplicated points // Clean up the boolean operation result by merging duplicated points
let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
@@ -236,12 +235,7 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
bake_paint_transforms(&mut attributes, copy_from_transform); bake_paint_transforms(&mut attributes, copy_from_transform);
let copy_from = vector.element(index).unwrap(); Item::from_parts(Vector::default(), attributes)
let element = Vector {
stroke: copy_from.stroke.clone(),
..Default::default()
};
Item::from_parts(element, attributes)
} else { } else {
Item::<Vector>::default() Item::<Vector>::default()
}; };
@@ -308,10 +302,7 @@ fn fill_appearance(paint: List<Graphic<'static>>) -> Appearance {
fn color_paint_row(color: Color, mut attributes: core_types::list::ItemAttributeValues) -> Item<Vector> { fn color_paint_row(color: Color, mut attributes: core_types::list::ItemAttributeValues) -> Item<Vector> {
attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(List::new_from_element(Graphic::Color(color)))); attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(List::new_from_element(Graphic::Color(color))));
let mut element = Vector::default(); Item::from_parts(Vector::default(), attributes)
element.set_stroke_transform(DAffine2::IDENTITY);
Item::from_parts(element, attributes)
} }
/// A gradient row: an empty vector carrying the stops as its fill paint, the /// A gradient row: an empty vector carrying the stops as its fill paint, the
@@ -329,10 +320,7 @@ fn gradient_paint_row(stops: GradientStops, mut attributes: core_types::list::It
} }
attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(gradient_paint)); attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(gradient_paint));
let mut element = Vector::default(); Item::from_parts(Vector::default(), attributes)
element.set_stroke_transform(DAffine2::IDENTITY);
Item::from_parts(element, attributes)
} }
/// A text lane's rows: the shaped glyph vectors under the composed transform. /// A text lane's rows: the shaped glyph vectors under the composed transform.
+79 -154
View File
@@ -132,10 +132,11 @@ fn assign_colors<'e>(
let mut appearance = existing_appearance.unwrap_or_default(); let mut appearance = existing_appearance.unwrap_or_default();
let paint_cell = Graphic::Graphic(paint); let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) { if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Above); appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
} }
if stroke && element.stroke.is_some() && !appearance.set_paint_of(Cover::Stroke, paint_cell.clone()) { // The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
appearance.replace_or_insert(Coverage::new_stroke(&element.stroke.clone().unwrap_or_default()), paint_cell, CoverPlacement::Above); if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
} }
let parked_appearance = park_appearance_attr(Some(appearance))?; let parked_appearance = park_appearance_attr(Some(appearance))?;
@@ -230,7 +231,6 @@ fn assign_colors_graphic<'e>(
let element = match rows { let element = match rows {
Some(mut rows) => { Some(mut rows) => {
for row in 0..rows.len() { for row in 0..rows.len() {
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 color = assign_color_at(gradient_element, position + row, length, randomize, seed, repeat_every);
let paint = List::new_from_element(color).into_graphic_list(); let paint = List::new_from_element(color).into_graphic_list();
@@ -238,10 +238,11 @@ fn assign_colors_graphic<'e>(
let mut appearance = rows.attribute_cloned_or_default::<Appearance>(graphic_types::ATTR_APPEARANCE, row); let mut appearance = rows.attribute_cloned_or_default::<Appearance>(graphic_types::ATTR_APPEARANCE, row);
let paint_cell = Graphic::Graphic(paint); let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) { if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Above); appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
} }
if stroke && row_stroke.is_some() && !appearance.set_paint_of(Cover::Stroke, paint_cell.clone()) { // The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
appearance.replace_or_insert(Coverage::new_stroke(&row_stroke.unwrap_or_default()), paint_cell, CoverPlacement::Above); if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
} }
rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance); rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance);
} }
@@ -421,7 +422,7 @@ fn stroke<'e>(
dash_offset: f64, dash_offset: f64,
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> { ) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
let dash_lengths: Vec<f64> = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect(); let dash_lengths: Vec<f64> = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect();
let mut stroke = Stroke { let stroke = Stroke {
weight, weight,
dash_lengths, dash_lengths,
dash_offset, dash_offset,
@@ -434,14 +435,9 @@ fn stroke<'e>(
// The coverage records the stroke's authoring space, so the item transform is composed in, translation // 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. // included so the render consumers see the exact legacy stroke space.
let mut coverage_stroke = stroke.clone(); let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform; coverage_stroke.transform *= *content_transform;
stroke.transform *= *content_transform;
let mut element = element;
element.stroke = Some(stroke);
let paint = paint_table(paint); let paint = paint_table(paint);
// The paint order is the coverage row order: appending above follows the painter's algorithm, and a // 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 // below stroke is expressed by the chain running the stroke node before the fill
@@ -450,23 +446,6 @@ fn stroke<'e>(
Ok((element, Attr(*content_transform), Attr(Some(parked_appearance)))) Ok((element, Attr(*content_transform), Attr(Some(parked_appearance))))
} }
/// The vector items of a graphic lane's interior, one wrap level deep, the
/// reach of the pre-flip broadcast over a legacy list.
fn for_each_interior_vector_mut(element: &mut Graphic, mut f: impl FnMut(&mut Vector, DAffine2)) {
match element {
Graphic::Vector(vector) => f(vector, DAffine2::IDENTITY),
Graphic::Graphic(children) => {
for index in 0..children.len() {
let transform: DAffine2 = children.attribute_cloned_or_default(ATTR_TRANSFORM, index);
if let Some(Graphic::Vector(vector)) = children.element_mut(index) {
f(vector, transform);
}
}
}
_ => {}
}
}
/// The stroke over graphic lanes: the style applies to the interior vectors, /// The stroke over graphic lanes: the style applies to the interior vectors,
/// the paint marker parks on the lane for the render boundary to place. /// the paint marker parks on the lane for the render boundary to place.
/// Registered under the stroke's identifier. /// Registered under the stroke's identifier.
@@ -498,16 +477,9 @@ fn stroke_graphic_leveled<'e>(
}; };
// The coverage records the stroke's authoring space at the lane, composing the lane transform 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(); let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform; coverage_stroke.transform *= *content_transform;
let mut element = element;
for_each_interior_vector_mut(&mut element, |vector, transform| {
let mut stroke = stroke.clone();
stroke.transform *= transform;
vector.stroke = Some(stroke);
});
let paint = paint_table(paint); let paint = paint_table(paint);
// The paint order is the coverage row order: appending above follows the painter's algorithm, and a // 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 // below stroke is expressed by the chain running the stroke node before the fill
@@ -654,10 +626,7 @@ fn round_corners(
// Convert 0-100 to 0-0.5 // Convert 0-100 to 0-0.5
let edge_length_limit = edge_length_limit * 0.005; let edge_length_limit = edge_length_limit * 0.005;
let mut result = Vector { let mut result = Vector::default();
stroke: source.stroke.clone(),
..Default::default()
};
// Grab the initial point ID as a stable starting point // Grab the initial point ID as a stable starting point
let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate()); let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate());
@@ -1016,8 +985,6 @@ fn box_warp(_: impl Ctx, (vector, transform): (Vector, Attr<TransformAttr>), #[e
}); });
} }
result.set_stroke_transform(DAffine2::IDENTITY);
// Reset the transform since we've applied it directly to the points // Reset the transform since we've applied it directly to the points
(result, Attr(DAffine2::IDENTITY)) (result, Attr(DAffine2::IDENTITY))
} }
@@ -1163,10 +1130,7 @@ fn auto_tangents(
) -> (Vector, Attr<TransformAttr>) { ) -> (Vector, Attr<TransformAttr>) {
let transform: DAffine2 = *lane_transform; let transform: DAffine2 = *lane_transform;
let mut result = Vector { let mut result = Vector::default();
stroke: source.stroke.clone(),
..Default::default()
};
for mut subpath in source.stroke_bezier_paths() { for mut subpath in source.stroke_bezier_paths() {
subpath.apply_transform(transform); subpath.apply_transform(transform);
@@ -1298,19 +1262,14 @@ fn auto_tangents(
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
fn bounding_box(_: impl Ctx, vector: Vector) -> Vector { fn bounding_box(_: impl Ctx, vector: Vector) -> Vector {
let mut result = vector vector
.bounding_box_rect() .bounding_box_rect()
.map(|bbox| { .map(|bbox| {
let mut vector = Vector::default(); let mut vector = Vector::default();
vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY)); vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY));
vector vector
}) })
.unwrap_or_default(); .unwrap_or_default()
result.stroke = vector.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
result
} }
#[node_macro::node(category("Vector: Measure"), path(core_types::vector))] #[node_macro::node(category("Vector: Measure"), path(core_types::vector))]
@@ -1361,11 +1320,7 @@ fn offset_path(_: impl Ctx, (vector, lane_transform): (Vector, Attr<TransformAtt
let transform = Affine::new(transform_attribute.to_cols_array()); let transform = Affine::new(transform_attribute.to_cols_array());
let bezpaths = vector.stroke_bezpath_iter(); let bezpaths = vector.stroke_bezpath_iter();
let mut result = Vector { let mut result = Vector::default();
stroke: vector.stroke.clone(),
..Default::default()
};
result.set_stroke_transform(DAffine2::IDENTITY);
// Perform operation on all subpaths in this shape. // Perform operation on all subpaths in this shape.
for mut bezpath in bezpaths { for mut bezpath in bezpaths {
@@ -1408,9 +1363,10 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
.into_iter() .into_iter()
.zip(has_fills) .zip(has_fills)
.flat_map(|(row, has_fill)| { .flat_map(|(row, has_fill)| {
let (mut vector, attributes) = row.into_parts(); let (vector, attributes) = row.into_parts();
let stroke = vector.stroke.clone().unwrap_or_default(); let appearance = attributes.get::<Appearance>(graphic_types::ATTR_APPEARANCE).cloned().unwrap_or_default();
let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default();
let bezpaths = vector.stroke_bezpath_iter(); let bezpaths = vector.stroke_bezpath_iter();
let mut solidified_stroke = Vector::default(); let mut solidified_stroke = Vector::default();
@@ -1461,11 +1417,9 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
solidified_stroke.append_bezpath(solidified); solidified_stroke.append_bezpath(solidified);
} }
// If the original vector has a fill, preserve it as a separate item with the stroke cleared. // If the original vector has a fill, preserve it as a separate item with the stroke coverages dropped.
let fill_row = has_fill.then(|| { let fill_row = has_fill.then(|| {
vector.stroke = None;
let mut fill_attributes = attributes.clone(); let mut fill_attributes = attributes.clone();
// No stroke remains on the fill row
if let Some(appearance) = fill_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) { if let Some(appearance) = fill_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) {
appearance.retain_cover(Cover::Fill); appearance.retain_cover(Cover::Fill);
} }
@@ -1735,7 +1689,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
return vec![row]; return vec![row];
} }
let stroke = row.element().stroke.clone();
let (_, attributes) = row.into_parts(); let (_, attributes) = row.into_parts();
bezpaths bezpaths
@@ -1743,7 +1696,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
.map(|bezpath| { .map(|bezpath| {
let mut vector = Vector::default(); let mut vector = Vector::default();
vector.append_bezpath(bezpath); vector.append_bezpath(bezpath);
vector.stroke = stroke.clone();
Item::from_parts(vector, attributes.clone()) Item::from_parts(vector, attributes.clone())
}) })
@@ -1869,15 +1821,11 @@ fn flatten_path_core<'e>(
let source_transform = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index); let source_transform = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index);
output.concat(element, source_transform, collision_hash_seed); output.concat(element, source_transform, collision_hash_seed);
// TODO: Make this instead use the first encountered stroke
// Use the last encountered stroke as the output stroke
output.stroke = element.stroke.clone();
primary_source = Some((index, source_transform)); primary_source = Some((index, source_transform));
} }
let mut fill_cell = None; // The primary row's appearance carries over whole, its paint transforms baked
let mut stroke_cell = None; let mut appearance = None;
let mut layer_path = Vec::new(); let mut layer_path = Vec::new();
if let Some((primary, source_transform)) = primary_source { if let Some((primary, source_transform)) = primary_source {
let source_attributes = flattened.clone_item_attributes(primary); let source_attributes = flattened.clone_item_attributes(primary);
@@ -1885,11 +1833,7 @@ fn flatten_path_core<'e>(
attributes.insert_cloned_from(&source_attributes, graphic_types::ATTR_APPEARANCE); attributes.insert_cloned_from(&source_attributes, graphic_types::ATTR_APPEARANCE);
bake_paint_transforms(&mut attributes, source_transform); bake_paint_transforms(&mut attributes, source_transform);
appearance = attributes.remove::<Appearance>(graphic_types::ATTR_APPEARANCE).and_then(|appearance| appearance.declared().cloned());
if let Some(appearance) = attributes.get::<Appearance>(graphic_types::ATTR_APPEARANCE) {
fill_cell = appearance.first_paint_of(Cover::Fill).filter(|cell| !cell.is_empty()).cloned();
stroke_cell = appearance.first_paint_of(Cover::Stroke).filter(|cell| !cell.is_empty()).cloned();
}
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer // Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
layer_path = flattened.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, primary); layer_path = flattened.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, primary);
@@ -1905,21 +1849,7 @@ fn flatten_path_core<'e>(
// editor click-target preservation, as the boolean operation does. // editor click-target preservation, as the boolean operation does.
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0; let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
// The carried paints land on the appearance, the stroke coverage recording the carried stroke's parameters let appearance = appearance.map(|appearance| park_appearance(arena, appearance)).transpose()?;
let appearance = {
let mut appearance = Appearance::default();
if let Some(cell) = fill_cell {
appearance.replace_or_insert(Coverage::new_fill(), cell, CoverPlacement::Above);
}
if let Some(cell) = stroke_cell {
let coverage = Coverage::new_stroke(&output.stroke.clone().unwrap_or_default());
appearance.replace_or_insert(coverage, cell, CoverPlacement::Above);
}
match appearance.declared().is_some() {
true => Some(park_appearance(arena, appearance)?),
false => None,
}
};
Ok((output, Attr(DAffine2::IDENTITY), Attr(appearance), Attr(layer_path.as_slice()), Attr(Some(merged_layers)))) Ok((output, Attr(DAffine2::IDENTITY), Attr(appearance), Attr(layer_path.as_slice()), Attr(Some(merged_layers))))
} }
@@ -1980,16 +1910,7 @@ fn sample_polyline(
} }
}; };
let mut element = element; let mut result = Vector::default();
let mut result = Vector {
point_domain: Default::default(),
segment_domain: Default::default(),
region_domain: Default::default(),
colinear_manipulators: Default::default(),
stroke: std::mem::take(&mut element.stroke),
};
// Transfer the stroke transform from the input vector content to the result.
result.set_stroke_transform(*transform);
for local_bezpath in element.stroke_bezpath_iter() { for local_bezpath in element.stroke_bezpath_iter() {
// Apply the transform to compute sample locations in world space (for correct distance-based spacing) // Apply the transform to compute sample locations in world space (for correct distance-based spacing)
@@ -2052,10 +1973,7 @@ fn simplify(
let transform = Affine::new(transform_attribute.to_cols_array()); let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse(); let inverse_transform = transform.inverse();
let mut result = Vector { let mut result = Vector::default();
stroke: content.stroke.clone(),
..Default::default()
};
for mut bezpath in content.stroke_bezpath_iter() { for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform); bezpath.apply_affine(transform);
@@ -2142,10 +2060,7 @@ fn decimate(
let transform = Affine::new(transform_attribute.to_cols_array()); let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse(); let inverse_transform = transform.inverse();
let mut result = Vector { let mut result = Vector::default();
stroke: content.stroke.clone(),
..Default::default()
};
for mut bezpath in content.stroke_bezpath_iter() { for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform); bezpath.apply_affine(transform);
@@ -2219,10 +2134,7 @@ fn cut_path_core(mut content: List<Vector>, progression: f64, reverse: bool, par
let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize }; let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize };
if let Some((row_index, bezpath)) = bezpaths.get(index).cloned() { if let Some((row_index, bezpath)) = bezpaths.get(index).cloned() {
let mut result_vector = Vector { let mut result_vector = Vector::default();
stroke: content.element(row_index).unwrap().stroke.clone(),
..Default::default()
};
for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) { for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) {
result_vector.append_bezpath(bezpath.clone()); result_vector.append_bezpath(bezpath.clone());
@@ -2463,8 +2375,6 @@ fn scatter_points(
} }
// Transfer the style from the input vector content to the result. // Transfer the style from the input vector content to the result.
result.stroke = element.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
result result
} }
@@ -2800,6 +2710,51 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
} }
} }
// Lerp between two appearances, pairing coverages by cover so a fill and a stroke never interpolate into each other.
// Stroke parameter pairs interpolate; other coverage pairings and the paint order step at the midpoint.
fn lerp_appearance(a: Option<&Appearance>, b: Option<&Appearance>, time: f64) -> Option<Appearance> {
if a.is_none() && b.is_none() {
return None;
}
let empty = Appearance::default();
let (a, b) = (a.unwrap_or(&empty), b.unwrap_or(&empty));
// The side holding the paint order at this time leads, so covers only the other side has follow behind it
let (leading, trailing) = if time < 0.5 { (a, b) } else { (b, a) };
let mut covers: Vec<Cover> = Vec::new();
for cover in leading.covers().chain(trailing.covers()).map(Coverage::cover) {
if !covers.contains(&cover) {
covers.push(cover);
}
}
let mut result = Appearance::default();
for cover in covers {
let (source_index, target_index) = (a.first_index_of(cover), b.first_index_of(cover));
// An unmatched stroke steps out at the midpoint, matching the stroke geometry, while an unmatched fill persists and fades
let coverage = match (source_index.and_then(|index| a.cover_at(index)), target_index.and_then(|index| b.cover_at(index))) {
(Some(source), Some(target)) if cover == Cover::Stroke => Coverage::new_stroke(&source.stroke_params().lerp(&target.stroke_params(), time)),
(Some(source), Some(target)) => (if time < 0.5 { source } else { target }).clone(),
(Some(_), None) if cover == Cover::Stroke && time >= 0.5 => continue,
(None, Some(_)) if cover == Cover::Stroke && time < 0.5 => continue,
(Some(source), None) => source.clone(),
(None, Some(target)) => target.clone(),
(None, None) => continue,
};
// An unmatched side falls to `None` here, which `lerp_graphic` fades against transparent.
// The paint cell carries its graphic list as one wrapped cell, so the lerp works on the unwrapped rows.
let source_paint = source_index.and_then(|index| a.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let target_paint = target_index.and_then(|index| b.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let paint = lerp_graphic(source_paint, target_paint, time).map(Graphic::Graphic).unwrap_or_default();
result.replace_or_insert(coverage, paint, CoverPlacement::Above);
}
Some(result)
}
// Preserve the original legacy snapshot as upstream data so this group layer's nested layers can be edited by the tools. // Preserve the original legacy snapshot as upstream data so this group layer's nested layers can be edited by the tools.
let mut graphic_list_content = snapshot; let mut graphic_list_content = snapshot;
@@ -3057,34 +3012,13 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
return List::new_from_item(Item::from_parts(endpoint_element.clone(), attributes)); return List::new_from_item(Item::from_parts(endpoint_element.clone(), attributes));
} }
let stroke = match (source_element.stroke.as_ref(), target_element.stroke.as_ref()) { let mut vector = Vector::default();
(Some(a), Some(b)) => Some(a.lerp(b, time)),
(Some(a), None) => {
if time < 0.5 {
Some(a.clone())
} else {
None
}
}
(None, Some(b)) => {
if time < 0.5 {
None
} else {
Some(b.clone())
}
}
(None, None) => None,
};
let mut vector = Vector { stroke, ..Default::default() };
let coverage_paint = |index: usize, cover: Cover| { let appearance = {
content let source = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, source_index);
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index) let target = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, target_index);
.and_then(|appearance| appearance.first_paint_of(cover)) lerp_appearance(source, target, time)
.and_then(graphic_types::graphic::paint_cell_rows)
}; };
let fill_paint = lerp_graphic(coverage_paint(source_index, Cover::Fill), coverage_paint(target_index, Cover::Fill), time);
let stroke_paint = lerp_graphic(coverage_paint(source_index, Cover::Stroke), coverage_paint(target_index, Cover::Stroke), time);
// Work directly with manipulator groups, bypassing the BezPath intermediate representation. // Work directly with manipulator groups, bypassing the BezPath intermediate representation.
// This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame. // This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame.
@@ -3243,16 +3177,7 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path) .with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
.with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content)); .with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content));
// The lerped paints land on the appearance, the stroke coverage recording the lerped stroke's parameters if let Some(appearance) = appearance {
let mut appearance = Appearance::default();
if let Some(fill) = fill_paint {
appearance.replace_or_insert(Coverage::new_fill(), Graphic::Graphic(fill), CoverPlacement::Above);
}
if let Some(stroke) = stroke_paint {
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); item.set_attribute(graphic_types::ATTR_APPEARANCE, appearance);
} }