From ff074b2a1c5379ba1d7122fe471717e3165ce9f6 Mon Sep 17 00:00:00 2001 From: Dennis Kobert Date: Thu, 10 Sep 2026 00:37:10 +0000 Subject: [PATCH] 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 --- .../document/data_panel/data_panel_message.rs | 2 +- .../data_panel/data_panel_message_handler.rs | 63 +---- .../portfolio/document/document_message.rs | 13 +- .../document/document_message_handler.rs | 41 +-- .../utility_types/document_metadata.rs | 25 +- .../utility_types/network_interface.rs | 14 +- editor/src/node_graph_executor.rs | 6 +- .../graphic-types/src/graphic/mod.rs | 5 +- node-graph/libraries/graphic-types/src/lib.rs | 25 +- .../libraries/rendering/src/renderer.rs | 37 ++- .../vector-types/src/vector/vector_types.rs | 39 +-- node-graph/nodes/gstd/src/lib.rs | 2 +- node-graph/nodes/path-bool/src/lib.rs | 18 +- node-graph/nodes/vector/src/vector_nodes.rs | 233 ++++++------------ 14 files changed, 168 insertions(+), 355 deletions(-) diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message.rs index a92808f966..cf35abca53 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message.rs @@ -37,8 +37,8 @@ pub enum PathStep { #[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)] pub enum VectorTableTab { #[default] - Properties, Points, Segments, Regions, + Handles, } diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 007df4731f..4043760397 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -376,7 +376,7 @@ impl TableItemLayout for Vector { ) } fn value_page(&self, data: &mut LayoutData) -> Vec { - 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() .map(|tab| { RadioEntryData::new(format!("{tab:?}")) @@ -388,57 +388,6 @@ impl TableItemLayout for Vector { let mut table_rows = Vec::new(); 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::>().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::>().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 => { table_rows.push(column_headings(&["", "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)] diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index 75c99ccd7c..e37216c51e 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -11,9 +11,8 @@ use crate::messages::portfolio::utility_types::PanelType; use crate::messages::prelude::*; use glam::{DAffine2, IVec2}; use graph_craft::document::NodeId; +use graphene_std::Appearance; use graphene_std::Color; -use graphene_std::Graphic; -use graphene_std::list::List; use graphene_std::raster::BlendMode; use graphene_std::raster::Image; use graphene_std::transform::Footprint; @@ -245,14 +244,10 @@ pub enum DocumentMessage { vector_data: HashMap>, }, // `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)] - UpdateFillAttributes { - fill_attributes: HashMap>>>, - }, - #[serde(skip)] - UpdateStrokeAttributes { - stroke_attributes: HashMap>>>, + UpdateAppearanceAttributes { + appearance_attributes: HashMap>, }, Undo, UngroupSelectedLayers, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 9a877f06d6..7165eb5ef2 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -37,7 +37,7 @@ use graph_craft::application_io::wgpu_available; use graph_craft::descriptor; use graph_craft::document::value::TaggedValue; 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::path_bool_nodes::boolean_intersect; use graphene_std::raster::BlendMode; @@ -1502,9 +1502,9 @@ impl MessageHandler> for DocumentMes .collect(); 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 - let layer_fill_attributes = fill_attributes + let layer_appearance_attributes = appearance_attributes .into_iter() .filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id)) .filter_map(|(node_id, attrs)| { @@ -1514,21 +1514,7 @@ impl MessageHandler> for DocumentMes }) }) .collect(); - self.network_interface.update_fill_attributes(layer_fill_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); + self.network_interface.update_appearance_attributes(layer_appearance_attributes); } DocumentMessage::Undo => { if self.network_interface.transaction_status() != TransactionStatus::Finished { @@ -2763,20 +2749,19 @@ impl DocumentMessageHandler { let mut resulting_layers: Vec = Vec::new(); 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()); continue; - }; - let stroke = vector_data.stroke.as_ref(); + } - let fill_graphic_list = self.network_interface.document_metadata().layer_fill_attributes.get(&layer); - let stroke_graphic_list = self.network_interface.document_metadata().layer_stroke_attributes.get(&layer); + let appearance = self.network_interface.document_metadata().layer_appearance_attributes.get(&layer); - let has_fill = fill_graphic_list.is_some_and(|list| is_paint_present(list)); - // `Vector.stroke` captures stroke geometry, even with weight 0 or transparent paint. - // So stroke visibility must be checked from the stroke paint snapshot, the paint source of truth. - let stroke_visible = stroke_graphic_list.is_some_and(|list| list.element(0).is_some_and(|g| !g.is_fully_transparent())); - let has_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()) && stroke_visible; + let has_fill = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill)); + // A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something + let has_stroke = appearance.is_some_and(|appearance| { + appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke()) + && 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. if !has_stroke { diff --git a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs index eb7f4ebf86..8e3d91c2ba 100644 --- a/editor/src/messages/portfolio/document/utility_types/document_metadata.rs +++ b/editor/src/messages/portfolio/document/utility_types/document_metadata.rs @@ -6,8 +6,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::Flow use crate::messages::tool::common_functionality::graph_modification_utils; use glam::{DAffine2, DVec2}; use graph_craft::document::NodeId; -use graphene_std::Graphic; -use graphene_std::list::List; +use graphene_std::Appearance; use graphene_std::math::quad::Quad; use graphene_std::subpath; 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. /// This provides accurate SegmentIds for layers without explicit Path nodes. pub layer_vector_data: HashMap>, - /// Per-layer fill paint snapshot, exposed so message handlers can read paint - /// information that lives on the list. - pub layer_fill_attributes: HashMap>>>, - /// Per-layer stroke paint snapshot, exposed so message handlers can read - /// stroke paint information that lives on the list. - pub layer_stroke_attributes: HashMap>>>, + /// Per-layer resolved appearance snapshot, exposed so message handlers can + /// read the paint and stroke parameters that live on the coverage rows. + pub layer_appearance_attributes: HashMap>, /// Transform from document space to viewport space. 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 /// bounds for non-vector layers (groups, raster, text, color, gradient). pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> { - 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)) - { - return Some(bounds); + if let Some(vector) = self.layer_vector_data.get(&layer) { + let stroke = self + .layer_appearance_attributes + .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) } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 61006afd49..1414f894c4 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -24,8 +24,7 @@ use graph_craft::Type; use graph_craft::application_io::resource::ResourceId; use graph_craft::document::value::TaggedValue; use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork}; -use graphene_std::Graphic; -use graphene_std::list::List; +use graphene_std::Appearance; use graphene_std::math::quad::Quad; use graphene_std::subpath::Subpath; use graphene_std::transform::Footprint; @@ -3439,14 +3438,9 @@ impl NodeNetworkInterface { self.document_metadata.layer_vector_data = new_layer_vector_data; } - /// Update the per-layer fill paint snapshot. - pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap>>>) { - self.document_metadata.layer_fill_attributes = new_layer_fill_attributes; - } - - /// Update the per-layer stroke paint snapshot. - pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap>>>) { - self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes; + /// Update the per-layer resolved appearance snapshot. + pub fn update_appearance_attributes(&mut self, new_layer_appearance_attributes: HashMap>) { + self.document_metadata.layer_appearance_attributes = new_layer_appearance_attributes; } } diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 9f6afb54c0..47f5d1fe87 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -715,8 +715,7 @@ impl NodeGraphExecutor { text_frames, clip_targets, vector_data, - fill_attributes, - stroke_attributes, + appearance_attributes, backgrounds: _, } = render_output.metadata; @@ -731,8 +730,7 @@ impl NodeGraphExecutor { responses.add(DocumentMessage::UpdateTextFrames { text_frames }); responses.add(DocumentMessage::UpdateClipTargets { clip_targets }); responses.add(DocumentMessage::UpdateVectorData { vector_data }); - responses.add(DocumentMessage::UpdateFillAttributes { fill_attributes }); - responses.add(DocumentMessage::UpdateStrokeAttributes { stroke_attributes }); + responses.add(DocumentMessage::UpdateAppearanceAttributes { appearance_attributes }); responses.add(DocumentMessage::RenderScrollbars); responses.add(DocumentMessage::RenderRulers); responses.add(OverlaysMessage::Draw); diff --git a/node-graph/libraries/graphic-types/src/graphic/mod.rs b/node-graph/libraries/graphic-types/src/graphic/mod.rs index 4550c16292..6b5824c425 100644 --- a/node-graph/libraries/graphic-types/src/graphic/mod.rs +++ b/node-graph/libraries/graphic-types/src/graphic/mod.rs @@ -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, diff --git a/node-graph/libraries/graphic-types/src/lib.rs b/node-graph/libraries/graphic-types/src/lib.rs index 104fdfb2e7..ba2e470b40 100644 --- a/node-graph/libraries/graphic-types/src/lib.rs +++ b/node-graph/libraries/graphic-types/src/lib.rs @@ -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, } @@ -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.)]); } } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index d05a5fb36d..c579356aad 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -540,12 +540,9 @@ pub struct RenderMetadata { pub text_frames: HashMap, pub clip_targets: HashSet, pub vector_data: HashMap>, - /// 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>>>, - /// 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>>>, + pub appearance_attributes: HashMap>, pub backgrounds: Vec, } @@ -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>(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>(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 ``/`` fully zeroes the path interior. // The wrapping SVG group (above) handles the user-set opacity. @@ -1759,8 +1753,7 @@ fn render_vector_vello>( } _ => { 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>( 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] diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index 38809ae5f5..cf1e965fe6 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -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, - /// 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, } } diff --git a/node-graph/nodes/gstd/src/lib.rs b/node-graph/nodes/gstd/src/lib.rs index 67dafe89db..7587b1f57d 100644 --- a/node-graph/nodes/gstd/src/lib.rs +++ b/node-graph/nodes/gstd/src/lib.rs @@ -12,7 +12,7 @@ pub use graphene_application_io as application_io; pub use graphene_core; pub use graphene_core::debug; 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 path_bool_nodes; pub use raster_nodes; diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 49da3fbcc0..69593fe9ed 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -54,7 +54,6 @@ fn boolean_core<'e>( let result_vector = result_vector_list.element_mut(0).unwrap(); Vector::transform(result_vector, transform); - result_vector.set_stroke_transform(DAffine2::IDENTITY); // Clean up the boolean operation result by merging duplicated points 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, boolean_operation: Bo bake_paint_transforms(&mut attributes, copy_from_transform); - let copy_from = vector.element(index).unwrap(); - let element = Vector { - stroke: copy_from.stroke.clone(), - ..Default::default() - }; - Item::from_parts(element, attributes) + Item::from_parts(Vector::default(), attributes) } else { Item::::default() }; @@ -308,10 +302,7 @@ fn fill_appearance(paint: List>) -> Appearance { fn color_paint_row(color: Color, mut attributes: core_types::list::ItemAttributeValues) -> Item { attributes.insert(graphic_types::ATTR_APPEARANCE, fill_appearance(List::new_from_element(Graphic::Color(color)))); - let mut element = Vector::default(); - element.set_stroke_transform(DAffine2::IDENTITY); - - Item::from_parts(element, attributes) + Item::from_parts(Vector::default(), attributes) } /// 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)); - let mut element = Vector::default(); - element.set_stroke_transform(DAffine2::IDENTITY); - - Item::from_parts(element, attributes) + Item::from_parts(Vector::default(), attributes) } /// A text lane's rows: the shaped glyph vectors under the composed transform. diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index 8919a94542..c3e1a62fab 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -132,10 +132,11 @@ fn assign_colors<'e>( let mut appearance = existing_appearance.unwrap_or_default(); let paint_cell = Graphic::Graphic(paint); 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()) { - appearance.replace_or_insert(Coverage::new_stroke(&element.stroke.clone().unwrap_or_default()), paint_cell, CoverPlacement::Above); + // The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke + if stroke { + appearance.set_paint_of(Cover::Stroke, paint_cell); } let parked_appearance = park_appearance_attr(Some(appearance))?; @@ -230,7 +231,6 @@ fn assign_colors_graphic<'e>( let element = match rows { Some(mut rows) => { 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 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::(graphic_types::ATTR_APPEARANCE, row); let paint_cell = Graphic::Graphic(paint); 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()) { - appearance.replace_or_insert(Coverage::new_stroke(&row_stroke.unwrap_or_default()), paint_cell, CoverPlacement::Above); + // The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke + if stroke { + appearance.set_paint_of(Cover::Stroke, paint_cell); } rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance); } @@ -421,7 +422,7 @@ fn stroke<'e>( dash_offset: f64, ) -> Result<(Vector, Attr, Attr<'e, AppearanceMarker>), Interrupt> { let dash_lengths: Vec = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect(); - let mut stroke = Stroke { + let stroke = Stroke { weight, dash_lengths, 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 // 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; - stroke.transform *= *content_transform; - - let mut element = element; - element.stroke = Some(stroke); - let paint = paint_table(paint); // 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 @@ -450,23 +446,6 @@ fn stroke<'e>( 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 paint marker parks on the lane for the render boundary to place. /// 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. - let mut coverage_stroke = stroke.clone(); + let mut coverage_stroke = stroke; 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); // 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 @@ -654,10 +626,7 @@ fn round_corners( // Convert 0-100 to 0-0.5 let edge_length_limit = edge_length_limit * 0.005; - let mut result = Vector { - stroke: source.stroke.clone(), - ..Default::default() - }; + let mut result = Vector::default(); // 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()); @@ -1016,8 +985,6 @@ fn box_warp(_: impl Ctx, (vector, transform): (Vector, Attr), #[e }); } - result.set_stroke_transform(DAffine2::IDENTITY); - // Reset the transform since we've applied it directly to the points (result, Attr(DAffine2::IDENTITY)) } @@ -1163,10 +1130,7 @@ fn auto_tangents( ) -> (Vector, Attr) { let transform: DAffine2 = *lane_transform; - let mut result = Vector { - stroke: source.stroke.clone(), - ..Default::default() - }; + let mut result = Vector::default(); for mut subpath in source.stroke_bezier_paths() { subpath.apply_transform(transform); @@ -1298,19 +1262,14 @@ fn auto_tangents( #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] fn bounding_box(_: impl Ctx, vector: Vector) -> Vector { - let mut result = vector + vector .bounding_box_rect() .map(|bbox| { let mut vector = Vector::default(); vector.append_bezpath(bbox.to_path(DEFAULT_ACCURACY)); vector }) - .unwrap_or_default(); - - result.stroke = vector.stroke.clone(); - result.set_stroke_transform(DAffine2::IDENTITY); - - result + .unwrap_or_default() } #[node_macro::node(category("Vector: Measure"), path(core_types::vector))] @@ -1361,11 +1320,7 @@ fn offset_path(_: impl Ctx, (vector, lane_transform): (Vector, Attr) -> List { .into_iter() .zip(has_fills) .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::(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 mut solidified_stroke = Vector::default(); @@ -1461,11 +1417,9 @@ fn solidify_rows(flattened: List) -> List { 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(|| { - vector.stroke = None; let mut fill_attributes = attributes.clone(); - // No stroke remains on the fill row if let Some(appearance) = fill_attributes.get_mut::(graphic_types::ATTR_APPEARANCE) { appearance.retain_cover(Cover::Fill); } @@ -1735,7 +1689,6 @@ fn separate_subpaths_core(content: List) -> List { return vec![row]; } - let stroke = row.element().stroke.clone(); let (_, attributes) = row.into_parts(); bezpaths @@ -1743,7 +1696,6 @@ fn separate_subpaths_core(content: List) -> List { .map(|bezpath| { let mut vector = Vector::default(); vector.append_bezpath(bezpath); - vector.stroke = stroke.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); 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)); } - let mut fill_cell = None; - let mut stroke_cell = None; + // The primary row's appearance carries over whole, its paint transforms baked + let mut appearance = None; let mut layer_path = Vec::new(); if let Some((primary, source_transform)) = primary_source { 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); bake_paint_transforms(&mut attributes, source_transform); - - if let Some(appearance) = attributes.get::(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(); - } + appearance = attributes.remove::(graphic_types::ATTR_APPEARANCE).and_then(|appearance| appearance.declared().cloned()); // 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::>(ATTR_EDITOR_LAYER_PATH, primary); @@ -1905,21 +1849,7 @@ fn flatten_path_core<'e>( // editor click-target preservation, as the boolean operation does. let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0; - // The carried paints land on the appearance, the stroke coverage recording the carried stroke's parameters - 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, - } - }; + let appearance = appearance.map(|appearance| park_appearance(arena, appearance)).transpose()?; 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 { - 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); + let mut result = Vector::default(); for local_bezpath in element.stroke_bezpath_iter() { // 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 inverse_transform = transform.inverse(); - let mut result = Vector { - stroke: content.stroke.clone(), - ..Default::default() - }; + let mut result = Vector::default(); for mut bezpath in content.stroke_bezpath_iter() { bezpath.apply_affine(transform); @@ -2142,10 +2060,7 @@ fn decimate( let transform = Affine::new(transform_attribute.to_cols_array()); let inverse_transform = transform.inverse(); - let mut result = Vector { - stroke: content.stroke.clone(), - ..Default::default() - }; + let mut result = Vector::default(); for mut bezpath in content.stroke_bezpath_iter() { bezpath.apply_affine(transform); @@ -2219,10 +2134,7 @@ fn cut_path_core(mut content: List, progression: f64, reverse: bool, par 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() { - let mut result_vector = Vector { - stroke: content.element(row_index).unwrap().stroke.clone(), - ..Default::default() - }; + let mut result_vector = Vector::default(); for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) { result_vector.append_bezpath(bezpath.clone()); @@ -2463,8 +2375,6 @@ fn scatter_points( } // Transfer the style from the input vector content to the result. - result.stroke = element.stroke.clone(); - result.set_stroke_transform(DAffine2::IDENTITY); result } @@ -2800,6 +2710,51 @@ fn morph_core(flattened: List, snapshot: List>, 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 { + 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 = 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. let mut graphic_list_content = snapshot; @@ -3057,34 +3012,13 @@ fn morph_core(flattened: List, snapshot: List>, progres 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()) { - (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 mut vector = Vector::default(); - let coverage_paint = |index: usize, cover: Cover| { - content - .attribute::(graphic_types::ATTR_APPEARANCE, index) - .and_then(|appearance| appearance.first_paint_of(cover)) - .and_then(graphic_types::graphic::paint_cell_rows) + let appearance = { + let source = content.attribute::(graphic_types::ATTR_APPEARANCE, source_index); + let target = content.attribute::(graphic_types::ATTR_APPEARANCE, target_index); + lerp_appearance(source, target, time) }; - 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. // This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame. @@ -3243,16 +3177,7 @@ fn morph_core(flattened: List, snapshot: List>, progres .with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path) .with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content)); - // The lerped paints land on the appearance, the stroke coverage recording the lerped stroke's parameters - 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() { + if let Some(appearance) = appearance { item.set_attribute(graphic_types::ATTR_APPEARANCE, appearance); }