Adopt the cascading "appearance" attribute in place of Vector::stroke and the "fill"/"paint" attributes (#4433)

* Add the appearance model types and attribute constants

* Dual-write the appearance attribute alongside the fill/stroke pair in all paint-writing nodes

* Read paint from the appearance attribute in the renderer, analysis, metadata, and editor, cascading from ancestors

* Retire the fill/stroke attribute pair and the Vector stroke field in favor of the appearance attribute

* Replace the Stroke node's paint order input with the relative chain order of the Fill and Stroke nodes

* Code review fixes

* Re-save demo art

* Stamp coverages in place and fuse the renderer's per-item appearance reads into single walks

* Treat an empty appearance as the undeclared state so padded rows inherit instead of blocking the cascade

* Update demo art

* Treat padded appearance rows as undeclared in the boolean flatten's group recursion
This commit is contained in:
Keavon Chambers
2026-08-14 13:25:23 -07:00
committed by GitHub
parent a034923695
commit d117c3eace
50 changed files with 1448 additions and 595 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -8,5 +8,7 @@ pub enum EventMessage {
CanvasTransformed, CanvasTransformed,
ToolAbort, ToolAbort,
SelectionChanged, SelectionChanged,
/// The document graph's nodes or wires changed, so state derived from the selection's chains may be stale
GraphChanged,
WorkingColorChanged, WorkingColorChanged,
} }
@@ -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,
} }
@@ -3,7 +3,7 @@ use crate::messages::layout::utility_types::layout_widget::{Layout, LayoutGroup,
use crate::messages::portfolio::document::data_panel::{DataPanelMessage, PathStep}; use crate::messages::portfolio::document::data_panel::{DataPanelMessage, PathStep};
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface; use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::shapes::shape_utility::{format_rounded, round_away_float_noise}; use crate::messages::tool::common_functionality::shapes::shape_utility::format_rounded;
use crate::messages::tool::tool_messages::tool_prelude::*; use crate::messages::tool::tool_messages::tool_prelude::*;
use glam::{Affine2, DAffine2, Vec2}; use glam::{Affine2, DAffine2, Vec2};
use graph_craft::document::NodeId; use graph_craft::document::NodeId;
@@ -18,6 +18,7 @@ use graphene_std::raster::{
CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
}; };
use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::subpath::BezierHandles;
use graphene_std::text::TextAlign; use graphene_std::text::TextAlign;
use graphene_std::text_nodes::StringCapitalization; use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transform::{ReferencePoint, ScaleType}; use graphene_std::transform::{ReferencePoint, ScaleType};
@@ -25,10 +26,10 @@ use graphene_std::vector::misc::{
ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
}; };
use graphene_std::vector::style::{ use graphene_std::vector::style::{
DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, DashPattern, FillChoice, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin,
}; };
use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector};
use graphene_std::{Artboard, Color, Context, Graphic}; use graphene_std::{Appearance, Artboard, Color, Context, Cover, Coverage, Graphic};
use std::any::Any; use std::any::Any;
use std::sync::Arc; use std::sync::Arc;
@@ -228,7 +229,6 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<StrokeJoin>, List<StrokeJoin>,
List<StrokeAlign>, List<StrokeAlign>,
List<StrokeCap>, List<StrokeCap>,
List<PaintOrder>,
List<MergeByDistanceAlgorithm>, List<MergeByDistanceAlgorithm>,
List<ExtrudeJoiningAlgorithm>, List<ExtrudeJoiningAlgorithm>,
List<PointSpacingType>, List<PointSpacingType>,
@@ -284,7 +284,6 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<StrokeJoin>, Item<StrokeJoin>,
Item<StrokeAlign>, Item<StrokeAlign>,
Item<StrokeCap>, Item<StrokeCap>,
Item<PaintOrder>,
Item<MergeByDistanceAlgorithm>, Item<MergeByDistanceAlgorithm>,
Item<ExtrudeJoiningAlgorithm>, Item<ExtrudeJoiningAlgorithm>,
Item<PointSpacingType>, Item<PointSpacingType>,
@@ -513,6 +512,45 @@ impl TableItemLayout for DashPattern {
} }
} }
impl TableItemLayout for Appearance {
fn type_name() -> &'static str {
"Appearance"
}
fn identifier(&self) -> String {
"Appearance".to_string()
}
// The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.value_page(data)
}
// Label the spreadsheet's element button with the inner list's identifier, like Artboard
fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec<WidgetInstance> {
self.0.value_widgets(target, data)
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.0.layout_with_breadcrumb(data)
}
}
impl TableItemLayout for Coverage {
fn type_name() -> &'static str {
"Coverage"
}
fn identifier(&self) -> String {
"Coverage".to_string()
}
// The wrapping row already contributes the breadcrumb; the inner item supplies the next level
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.value_page(data)
}
fn value_widgets(&self, target: PathStep, data: &LayoutData) -> Vec<WidgetInstance> {
self.0.value_widgets(target, data)
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.0.layout_with_breadcrumb(data)
}
}
impl TableItemLayout for BoxCorners { impl TableItemLayout for BoxCorners {
fn type_name() -> &'static str { fn type_name() -> &'static str {
"BoxCorners" "BoxCorners"
@@ -581,7 +619,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:?}"))
@@ -593,89 +631,49 @@ 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 => { VectorTableTab::Handles => {
table_rows.push(column_headings(&["property", "value"])); table_rows.push(column_headings(&["", "colinear_manipulators[0]", "colinear_manipulators[1]"]));
table_rows.extend(self.colinear_manipulators.iter().enumerate().map(|(index, [a, b])| {
if let Some(stroke) = self.stroke.as_ref() { vec![
table_rows.push(vec![ TextLabel::new(format!("{index}")).narrow(true).widget_instance(),
TextLabel::new("Stroke Weight").narrow(true).widget_instance(), TextLabel::new(format!("{a}")).narrow(true).widget_instance(),
TextLabel::new(format!("{} px", stroke.weight)).narrow(true).widget_instance(), TextLabel::new(format!("{b}")).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(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Paint Order").narrow(true).widget_instance(),
TextLabel::new(stroke.paint_order.to_string()).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)| {
let position = DVec2::new(round_away_float_noise(position.x), round_away_float_noise(position.y));
vec![ vec![
TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(), TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(),
TextLabel::new(format!("{position}")).narrow(true).widget_instance(), TextLabel::new(format_dvec2(position)).narrow(true).widget_instance(),
] ]
})); }));
} }
VectorTableTab::Segments => { VectorTableTab::Segments => {
table_rows.push(column_headings(&["", "start_index", "end_index", "handles"])); table_rows.push(column_headings(&["", "start_point", "end_point", "handles"]));
table_rows.extend(self.segment_domain.iter().map(|(id, start, end, handles)| { table_rows.extend(self.segment_domain.iter().map(|(id, start, end, handles)| {
let handles = match handles {
BezierHandles::Linear => "Linear".to_string(),
BezierHandles::Quadratic { handle } => format!("Quadratic — {}", format_dvec2(handle)),
BezierHandles::Cubic { handle_start, handle_end } => format!("Cubic — start: {}, end: {}", format_dvec2(handle_start), format_dvec2(handle_end)),
};
vec![ vec![
TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(), TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(),
TextLabel::new(format!("{start}")).narrow(true).widget_instance(), TextLabel::new(format!("Point {start}")).narrow(true).widget_instance(),
TextLabel::new(format!("{end}")).narrow(true).widget_instance(), TextLabel::new(format!("Point {end}")).narrow(true).widget_instance(),
TextLabel::new(format!("{handles:?}")).narrow(true).widget_instance(), TextLabel::new(handles).narrow(true).widget_instance(),
] ]
})); }));
} }
VectorTableTab::Regions => { VectorTableTab::Regions => {
table_rows.push(column_headings(&["", "segment_range", "fill"])); table_rows.push(column_headings(&["", "segment_range"]));
table_rows.extend(self.region_domain.iter().map(|(id, segment_range, fill)| { table_rows.extend(self.region_domain.iter().map(|(id, segment_range, _)| {
vec![ vec![
TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(), TextLabel::new(format!("{}", id.inner())).narrow(true).widget_instance(),
TextLabel::new(format!("{segment_range:?}")).narrow(true).widget_instance(), TextLabel::new(format!("Segment {} – Segment {}", segment_range.start().inner(), segment_range.end().inner()))
TextLabel::new(format!("{}", fill.inner())).narrow(true).widget_instance(), .narrow(true)
.widget_instance(),
] ]
})); }));
} }
@@ -1019,6 +1017,7 @@ macro_rules! impl_table_item_layout_for_choice_enum {
} }
impl_table_item_layout_for_choice_enum!( impl_table_item_layout_for_choice_enum!(
BlendMode, BlendMode,
Cover,
GradientForm, GradientForm,
GradientSpread, GradientSpread,
GradientSpace, GradientSpace,
@@ -1027,7 +1026,6 @@ impl_table_item_layout_for_choice_enum!(
StrokeJoin, StrokeJoin,
StrokeAlign, StrokeAlign,
StrokeCap, StrokeCap,
PaintOrder,
MergeByDistanceAlgorithm, MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm, ExtrudeJoiningAlgorithm,
PointSpacingType, PointSpacingType,
@@ -1231,6 +1229,9 @@ macro_rules! known_item_types {
Raster<GPU>, Raster<GPU>,
Graphic, Graphic,
Artboard, Artboard,
Appearance,
Coverage,
Cover,
DashPattern, DashPattern,
BoxCorners, BoxCorners,
BlendMode, BlendMode,
@@ -1242,7 +1243,6 @@ macro_rules! known_item_types {
StrokeJoin, StrokeJoin,
StrokeAlign, StrokeAlign,
StrokeCap, StrokeCap,
PaintOrder,
MergeByDistanceAlgorithm, MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm, ExtrudeJoiningAlgorithm,
PointSpacingType, PointSpacingType,
@@ -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>>>, appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
},
#[serde(skip)]
UpdateStrokeAttributes {
stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
}, },
Undo, Undo,
UngroupSelectedLayers, UngroupSelectedLayers,
@@ -37,7 +37,7 @@ use graph_craft::application_io::wgpu_available;
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 graph_craft::list; use graph_craft::list;
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,21 @@ 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 `ATTR_STROKE`, 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.element(0).is_some_and(|graphic| !graphic.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 {
@@ -10,7 +10,7 @@ use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke};
use graphene_std::vector::{Gradient, PointId, VectorModificationType}; use graphene_std::vector::{Gradient, PointId, VectorModificationType};
#[impl_message(Message, DocumentMessage, GraphOperation)] #[impl_message(Message, DocumentMessage, GraphOperation)]
@@ -93,6 +93,10 @@ pub enum GraphOperationMessage {
color: Option<Color>, color: Option<Color>,
stroke: Stroke, stroke: Stroke,
}, },
StrokeOrderSet {
layer: LayerNodeIdentifier,
paint_order: PaintOrder,
},
TransformChange { TransformChange {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
transform: DAffine2, transform: DAffine2,
@@ -1,8 +1,8 @@
use super::transform_utils; use super::transform_utils;
use super::utility_types::ModifyInputsContext; use super::utility_types::{ModifyInputsContext, set_stroke_paint_order};
use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP}; use crate::consts::{LAYER_INDENT_OFFSET, STACK_VERTICAL_GAP};
use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn; use crate::messages::portfolio::document::graph_operation::utility_types::TransformIn;
use crate::messages::portfolio::document::node_graph::document_node_definitions::BLEND_PATH_INPUT_INDEX; use crate::messages::portfolio::document::node_graph::document_node_definitions::{BLEND_PATH_INPUT_INDEX, DefinitionIdentifier};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector};
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
@@ -13,7 +13,7 @@ use graph_craft::document::{NodeId, NodeInput};
use graph_craft::list; use graph_craft::list;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path; use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Gradient, GradientForm, GradientSettings, GradientSpace, GradientSpread, GradientStop, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::style::{Gradient, GradientForm, GradientSettings, GradientSpace, GradientSpread, GradientStop, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::{Artboard, Color}; use graphene_std::{Artboard, Color};
#[derive(ExtractField)] #[derive(ExtractField)]
@@ -131,6 +131,17 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.stroke_set(color, stroke); modify_inputs.stroke_set(color, stroke);
} }
} }
GraphOperationMessage::StrokeOrderSet { layer, paint_order } => {
let stroke_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let Some(stroke_node_id) = ModifyInputsContext::locate_node_in_layer_chain(&stroke_reference, layer, network_interface) else {
return;
};
if set_stroke_paint_order(network_interface, &[], stroke_node_id, paint_order) {
responses.add(PropertiesPanelMessage::Refresh);
responses.add(NodeGraphMessage::RunDocumentGraph);
responses.add(NodeGraphMessage::SendGraph);
}
}
GraphOperationMessage::TransformChange { GraphOperationMessage::TransformChange {
layer, layer,
transform, transform,
@@ -944,7 +955,6 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont
}, },
join_miter_limit: stroke.miterlimit().get() as f64, join_miter_limit: stroke.miterlimit().get() as f64,
align: StrokeAlign::Center, align: StrokeAlign::Center,
paint_order: PaintOrder::StrokeAbove,
transform, transform,
}, },
) )
@@ -1026,6 +1036,81 @@ fn apply_usvg_fill(fill: &usvg::Fill, modify_inputs: &mut ModifyInputsContext, g
mod tests { mod tests {
use super::*; use super::*;
#[tokio::test]
async fn stroke_order_set_reorders_the_fill_and_stroke_nodes() {
use crate::messages::tool::common_functionality::graph_modification_utils::get_stroke_paint_order;
use crate::test_utils::test_prelude::*;
use graphene_std::vector::style::PaintOrder;
let mut editor = EditorTestUtils::create();
editor.new_document().await;
editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await;
let document = editor.active_document();
let layer = document.metadata().all_layers().next().unwrap();
let paint_order = get_stroke_paint_order(layer, &document.network_interface);
assert_eq!(paint_order, PaintOrder::StrokeAbove, "a fresh shape should stroke above its fill");
editor
.handle_message(GraphOperationMessage::StrokeOrderSet {
layer,
paint_order: PaintOrder::StrokeBelow,
})
.await;
let paint_order = get_stroke_paint_order(layer, &editor.active_document().network_interface);
assert_eq!(paint_order, PaintOrder::StrokeBelow, "the rewrite should move the stroke downstream of the fill");
editor
.handle_message(GraphOperationMessage::StrokeOrderSet {
layer,
paint_order: PaintOrder::StrokeAbove,
})
.await;
let paint_order = get_stroke_paint_order(layer, &editor.active_document().network_interface);
assert_eq!(paint_order, PaintOrder::StrokeAbove, "the rewrite should move the stroke back upstream of the fill");
}
#[tokio::test]
async fn a_node_inserted_between_the_fill_and_stroke_makes_the_order_swap_inapplicable() {
use crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_proto_node_type;
use crate::messages::tool::common_functionality::graph_modification_utils::stroke_paint_order_applicable;
use crate::test_utils::test_prelude::*;
let mut editor = EditorTestUtils::create();
editor.new_document().await;
editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await;
let layer = editor.active_document().metadata().all_layers().next().unwrap();
assert!(
stroke_paint_order_applicable(layer, &editor.active_document().network_interface),
"a fresh shape's adjacent Fill and Stroke pair should be swappable"
);
let stroke_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let stroke_node_id = ModifyInputsContext::locate_node_in_layer_chain(&stroke_reference, layer, &editor.active_document().network_interface).unwrap();
let node_template = Box::new(resolve_proto_node_type(graphene_std::ops::passthrough::IDENTIFIER).unwrap().default_node_template());
let passthrough_node_id = NodeId::new();
editor
.handle_message(NodeGraphMessage::InsertNode {
node_id: passthrough_node_id,
node_template,
})
.await;
editor
.handle_message(NodeGraphMessage::InsertNodeBetween {
node_id: passthrough_node_id,
input_connector: InputConnector::node_at_index(stroke_node_id, 0),
insert_node_input_index: 0,
})
.await;
assert!(
!stroke_paint_order_applicable(layer, &editor.active_document().network_interface),
"a node between the pair should gray the order radio out"
);
}
#[test] #[test]
fn color_interpolation_resolves_per_gradient_with_inheritance_and_style_priority() { fn color_interpolation_resolves_per_gradient_with_inheritance_and_style_priority() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" color-interpolation="linearRGB"> let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" color-interpolation="linearRGB">
@@ -3,7 +3,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
ARTBOARD_DIMENSIONS_INPUT_INDEX, ARTBOARD_LOCATION_INPUT_INDEX, DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type, ARTBOARD_DIMENSIONS_INPUT_INDEX, ARTBOARD_LOCATION_INPUT_INDEX, DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type,
}; };
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface}; use crate::messages::portfolio::document::utility_types::network_interface::{self, FlowType, InputConnector, NodeNetworkInterface, OutputConnector};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::{ use crate::messages::tool::common_functionality::graph_modification_utils::{
ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain, ReplaceablePaintChain, get_fill_input_node_id, get_upstream_gradient_value_node_id, gradient_chain_target_input, replaceable_paint_chain,
@@ -18,7 +18,7 @@ use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath; use graphene_std::subpath::Subpath;
use graphene_std::text::{Font, TypesettingConfig}; use graphene_std::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, Stroke}; use graphene_std::vector::style::{GradientForm, GradientHueDirection, GradientInterpolation, GradientSettings, GradientSpace, GradientSpread, PaintOrder, Stroke};
use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType}; use graphene_std::vector::{Gradient, GradientRamp, PointId, Vector, VectorModification, VectorModificationType};
use graphene_std::{Artboard, Color, Graphic}; use graphene_std::{Artboard, Color, Graphic};
@@ -431,7 +431,8 @@ impl<'a> ModifyInputsContext<'a> {
} }
pub fn fill_color_set(&mut self, color: Option<Color>) { pub fn fill_color_set(&mut self, color: Option<Color>) {
let Some(fill_node_id) = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { let existing_fill_node_id = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, false);
let Some(fill_node_id) = existing_fill_node_id.or_else(|| self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true)) else {
return; return;
}; };
let input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::FillInput); let input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::FillInput);
@@ -443,10 +444,15 @@ impl<'a> ModifyInputsContext<'a> {
} }
let fill_value = color.map_or_else(TaggedValue::no_paint, TaggedValue::Color); let fill_value = color.map_or_else(TaggedValue::no_paint, TaggedValue::Color);
self.set_input_with_refresh(input_connector, NodeInput::value(fill_value, false), false); self.set_input_with_refresh(input_connector, NodeInput::value(fill_value, false), false);
if existing_fill_node_id.is_none() {
self.restore_default_stroke_order();
}
} }
pub fn fill_gradient_set(&mut self, gradient: Gradient, gradient_form: GradientForm, settings: GradientSettings, transform: DAffine2) { pub fn fill_gradient_set(&mut self, gradient: Gradient, gradient_form: GradientForm, settings: GradientSettings, transform: DAffine2) {
let Some(fill_node_id) = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true) else { let existing_fill_node_id = self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, false);
let Some(fill_node_id) = existing_fill_node_id.or_else(|| self.existing_chain_hosted_node_id(graphene_std::vector_nodes::fill::IDENTIFIER, true)) else {
return; return;
}; };
let backup_input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupGradientInput); let backup_input_connector = InputConnector::node(fill_node_id, graphene_std::vector::fill::BackupGradientInput);
@@ -487,6 +493,21 @@ impl<'a> ModifyInputsContext<'a> {
NodeInput::value(TaggedValue::GradientForm(gradient_form), false), NodeInput::value(TaggedValue::GradientForm(gradient_form), false),
false, false,
); );
if existing_fill_node_id.is_none() {
self.restore_default_stroke_order();
}
}
/// A freshly created Fill node lands at the chain start, downstream of any Stroke node, where it would
/// paint over the stroke. This hops the stroke back downstream so it keeps painting above by default.
fn restore_default_stroke_order(&mut self) {
let Some(output_layer) = self.get_output_layer() else { return };
let stroke_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let Some(stroke_node_id) = Self::locate_node_in_layer_chain(&stroke_reference, output_layer, self.network_interface) else {
return;
};
set_stroke_paint_order(self.network_interface, &[], stroke_node_id, PaintOrder::StrokeAbove);
} }
pub fn blend_mode_set(&mut self, blend_mode: BlendMode) { pub fn blend_mode_set(&mut self, blend_mode: BlendMode) {
@@ -857,8 +878,6 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::StrokeJoin(stroke.join), false), true); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::StrokeJoin(stroke.join), false), true);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::MiterLimitInput); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::MiterLimitInput);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.join_miter_limit), false), false); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.join_miter_limit), false), false);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::PaintOrderInput);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::PaintOrder(stroke.paint_order), false), false);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashPatternInput); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashPatternInput);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::DashPattern(stroke.dash_lengths), false), true); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::DashPattern(stroke.dash_lengths), false), true);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashOffsetInput); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashOffsetInput);
@@ -1006,3 +1025,60 @@ impl<'a> ModifyInputsContext<'a> {
} }
} }
} }
/// The wires feeding off a node's primary output.
fn primary_output_consumers(network_interface: &mut NodeNetworkInterface, network_path: &[NodeId], node_id: NodeId) -> Vec<InputConnector> {
network_interface
.outward_wires(network_path)
.and_then(|wires| wires.get(&OutputConnector::node(node_id, 0)).cloned())
.unwrap_or_default()
}
/// Swaps a chain's directly adjacent Stroke and Fill nodes when their order disagrees with the requested
/// paint order: both nodes append their cover, so the downstream one of the pair paints on top, following
/// the painter's algorithm. Without a fill wired directly to the stroke, nothing changes.
/// Returns whether the graph changed.
pub fn set_stroke_paint_order(network_interface: &mut NodeNetworkInterface, network_path: &[NodeId], stroke_node_id: NodeId, paint_order: PaintOrder) -> bool {
let fill_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER);
let is_fill = |network_interface: &NodeNetworkInterface, node_id: &NodeId| network_interface.reference(node_id, network_path).as_ref() == Some(&fill_reference);
// Find the fill wired directly to the stroke on either side; the downstream one of the pair paints on top
let stroke_primary_source = match network_interface.input_from_connector(&InputConnector::node_at_index(stroke_node_id, 0), network_path) {
Some(NodeInput::Node { node_id, output_index: 0, .. }) => Some(*node_id),
_ => None,
};
let (fill_node_id, currently_above) = if let Some(source) = stroke_primary_source.filter(|source| is_fill(network_interface, source)) {
(source, true)
} else {
let consumers = primary_output_consumers(network_interface, network_path, stroke_node_id);
let fill_consumer = consumers.iter().find_map(|connector| match connector {
InputConnector::Node { node_id, input_index: 0 } if is_fill(network_interface, node_id) => Some(*node_id),
_ => None,
});
let Some(fill_node_id) = fill_consumer else { return false };
(fill_node_id, false)
};
if (paint_order == PaintOrder::StrokeAbove) == currently_above {
return false;
}
// Swap the pair in place: the downstream node takes the upstream one's source, consumers of the
// downstream node move over to the upstream one, and the wire linking the pair reverses direction
let (upstream, downstream) = if currently_above { (fill_node_id, stroke_node_id) } else { (stroke_node_id, fill_node_id) };
let Some(upstream_source) = network_interface.input_from_connector(&InputConnector::node_at_index(upstream, 0), network_path).cloned() else {
return false;
};
let downstream_consumers = primary_output_consumers(network_interface, network_path, downstream);
network_interface.set_input(&InputConnector::node_at_index(downstream, 0), upstream_source, network_path);
network_interface.set_input(&InputConnector::node_at_index(upstream, 0), NodeInput::node(downstream, 0), network_path);
for consumer in &downstream_consumers {
if matches!(consumer, InputConnector::Node { node_id, .. } if *node_id == upstream || *node_id == downstream) {
continue;
}
network_interface.set_input(consumer, NodeInput::node(upstream, 0), network_path);
}
true
}
@@ -1734,6 +1734,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
responses.add(NodeGraphMessage::UpdateLayerPanel); responses.add(NodeGraphMessage::UpdateLayerPanel);
responses.add(DocumentMessage::DocumentStructureChanged); responses.add(DocumentMessage::DocumentStructureChanged);
responses.add(PropertiesPanelMessage::Refresh); responses.add(PropertiesPanelMessage::Refresh);
responses.add(EventMessage::GraphChanged);
if breadcrumb_network_path == selection_network_path && graph_view_overlay_open { if breadcrumb_network_path == selection_network_path && graph_view_overlay_open {
let nodes = self.collect_nodes(network_interface, breadcrumb_network_path); let nodes = self.collect_nodes(network_interface, breadcrumb_network_path);
self.frontend_nodes = nodes.iter().map(|node| node.id).collect(); self.frontend_nodes = nodes.iter().map(|node| node.id).collect();
@@ -33,8 +33,8 @@ use graphene_std::vector::misc::{
ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
}; };
use graphene_std::vector::style::{ use graphene_std::vector::style::{
FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, PaintOrder, StrokeAlign, StrokeCap, FillChoice, Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStops, StrokeAlign, StrokeCap, StrokeJoin,
StrokeJoin, build_transform_with_y_preservation, build_transform_with_y_preservation,
}; };
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
use graphene_std::{NodeParameter, ParameterRef}; use graphene_std::{NodeParameter, ParameterRef};
@@ -322,7 +322,6 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(), Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(), Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeAlign>(x) => enum_choice::<StrokeAlign>().for_socket(default_info).property_row(), Some(x) if id_is::<StrokeAlign>(x) => enum_choice::<StrokeAlign>().for_socket(default_info).property_row(),
Some(x) if id_is::<PaintOrder>(x) => enum_choice::<PaintOrder>().for_socket(default_info).property_row(),
Some(x) if id_is::<ArcType>(x) => enum_choice::<ArcType>().for_socket(default_info).property_row(), Some(x) if id_is::<ArcType>(x) => enum_choice::<ArcType>().for_socket(default_info).property_row(),
Some(x) if id_is::<RowsOrColumns>(x) => enum_choice::<RowsOrColumns>().for_socket(default_info).property_row(), Some(x) if id_is::<RowsOrColumns>(x) => enum_choice::<RowsOrColumns>().for_socket(default_info).property_row(),
Some(x) if id_is::<TextAlign>(x) => enum_choice::<TextAlign>().for_socket(default_info).property_row(), Some(x) if id_is::<TextAlign>(x) => enum_choice::<TextAlign>().for_socket(default_info).property_row(),
@@ -2678,9 +2677,6 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
ParameterWidgetsInfo::new(node_id, MiterLimitInput, true, context), ParameterWidgetsInfo::new(node_id, MiterLimitInput, true, context),
NumberInput::default().min(0.).disabled(miter_limit_disabled), NumberInput::default().min(0.).disabled(miter_limit_disabled),
); );
let paint_order = enum_choice::<PaintOrder>()
.for_socket(ParameterWidgetsInfo::new(node_id, PaintOrderInput, true, context))
.property_row();
let disabled_number_input = NumberInput::default().unit(" px").disabled(has_dash_lengths); let disabled_number_input = NumberInput::default().unit(" px").disabled(has_dash_lengths);
let dash_lengths = dash_pattern_widget(ParameterWidgetsInfo::new(node_id, DashPatternInput, true, context), TextInput::default().centered(true)); let dash_lengths = dash_pattern_widget(ParameterWidgetsInfo::new(node_id, DashPatternInput, true, context), TextInput::default().centered(true));
let number_input = disabled_number_input; let number_input = disabled_number_input;
@@ -2693,7 +2689,6 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
cap, cap,
join, join,
LayoutGroup::row(miter_limit), LayoutGroup::row(miter_limit),
paint_order,
LayoutGroup::row(dash_lengths), LayoutGroup::row(dash_lengths),
LayoutGroup::row(dash_offset), LayoutGroup::row(dash_offset),
] ]
@@ -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,8 @@ 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 `ATTR_FILL` attribute, exposed so message handlers can read paint /// Per-layer `ATTR_APPEARANCE` attribute, exposed so message handlers can read paint information that lives on the list.
/// information that lives on the list. pub layer_appearance_attributes: HashMap<LayerNodeIdentifier, Arc<Appearance>>,
pub layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>,
/// Per-layer `ATTR_STROKE` attribute, exposed so message handlers can read
/// stroke paint information that lives on the list.
pub layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>,
/// 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 +228,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)
} }
@@ -40,9 +40,8 @@ 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::Appearance;
use graphene_std::ContextDependencies; use graphene_std::ContextDependencies;
use graphene_std::Graphic;
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;
@@ -183,13 +183,8 @@ 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 `ATTR_FILL` snapshot. /// Update the per-layer `ATTR_APPEARANCE` snapshot.
pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>) { 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 `ATTR_STROKE` snapshot.
pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic>>>) {
self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes;
} }
} }
@@ -1,6 +1,7 @@
// TODO: Eventually remove this document upgrade code // TODO: Eventually remove this document upgrade code
// This file contains lots of hacky code for upgrading old documents to the new format // This file contains lots of hacky code for upgrading old documents to the new format
use crate::messages::portfolio::document::graph_operation::utility_types::set_stroke_paint_order;
use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type}; use crate::messages::portfolio::document::node_graph::document_node_definitions::{DefinitionIdentifier, resolve_document_node_type, resolve_network_node_type, resolve_proto_node_type};
use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier; use crate::messages::portfolio::document::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, NodeTemplateImplementation, OutputConnector}; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, NodeTemplateImplementation, OutputConnector};
@@ -1807,13 +1808,12 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
inputs_count = 7; inputs_count = 7;
} }
// Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644) // Upgrade Stroke node to reorder parameters and add "Align" (#2644)
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 8 { if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 8 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
let align_input = NodeInput::value(TaggedValue::StrokeAlign(StrokeAlign::Center), false); let align_input = NodeInput::value(TaggedValue::StrokeAlign(StrokeAlign::Center), false);
let paint_order_input = NodeInput::value(TaggedValue::PaintOrder(PaintOrder::StrokeAbove), false);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[1].clone(), network_path);
@@ -1822,13 +1822,36 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 4), old_inputs[5].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 4), old_inputs[5].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 5), old_inputs[6].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 5), old_inputs[6].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 6), old_inputs[7].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 6), old_inputs[7].clone(), network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), paint_order_input, network_path);
let dash_input = migrate_dash_input(&old_inputs[3]).unwrap_or_else(|| old_inputs[3].clone()); let dash_input = migrate_dash_input(&old_inputs[3]).unwrap_or_else(|| old_inputs[3].clone());
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 8), dash_input, network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), dash_input, network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 9), old_inputs[4].clone(), network_path); document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 8), old_inputs[4].clone(), network_path);
inputs_count = 9;
}
// The Stroke node's "Paint Order" input was retired in favor of the relative order of the Fill and Stroke
// nodes in the chain, so the stored value becomes a topology rewrite that reorders the two nodes.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER) && inputs_count == 10 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
// A wired paint order input cannot be evaluated statically, so it degrades to the default and leaves its source disconnected
let paint_order = match old_inputs.get(7).and_then(|input| input.as_value()) {
Some(&TaggedValue::PaintOrder(value)) => value,
_ => PaintOrder::StrokeAbove,
};
for (index, input) in old_inputs.iter().enumerate().take(7) {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
let dash_input = migrate_dash_input(&old_inputs[8]).unwrap_or_else(|| old_inputs[8].clone());
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), dash_input, network_path);
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 8), old_inputs[9].clone(), network_path);
inputs_count = 9;
set_stroke_paint_order(&mut document.network_interface, network_path, *node_id, paint_order);
} }
// TODO: Eventually remove this document upgrade code
// A legacy "no color" on a plain color connector (`TaggedValue::no_paint()` restored by the deserializer) becomes a color, // A legacy "no color" on a plain color connector (`TaggedValue::no_paint()` restored by the deserializer) becomes a color,
// since only paint connectors keep the no-paint choice // since only paint connectors keep the no-paint choice
{ {
@@ -125,6 +125,9 @@ pub struct DrawingToolState {
pub miter_limit: Option<f64>, pub miter_limit: Option<f64>,
/// Paint order from the selection. `None` = mixed. /// Paint order from the selection. `None` = mixed.
pub paint_order: Option<PaintOrder>, pub paint_order: Option<PaintOrder>,
/// Whether any selected layer has a lone adjacent Fill/Stroke pair for the paint order to swap.
/// Stays true with an empty selection, where the radio still sets the default for new shapes.
pub paint_order_applicable: bool,
/// Dash lengths from the selection. `None` = mixed. /// Dash lengths from the selection. `None` = mixed.
pub dash_lengths: Option<Vec<f64>>, pub dash_lengths: Option<Vec<f64>>,
/// Dash offset from the selection. `None` = mixed. /// Dash offset from the selection. `None` = mixed.
@@ -150,6 +153,7 @@ impl DrawingToolState {
stroke_join: Some(StrokeJoin::default()), stroke_join: Some(StrokeJoin::default()),
miter_limit: Some(4.), miter_limit: Some(4.),
paint_order: Some(PaintOrder::default()), paint_order: Some(PaintOrder::default()),
paint_order_applicable: true,
dash_lengths: Some(Vec::new()), dash_lengths: Some(Vec::new()),
dash_offset: Some(0.), dash_offset: Some(0.),
last_synced_selection: Vec::new(), last_synced_selection: Vec::new(),
@@ -183,13 +187,21 @@ impl DrawingToolState {
cap: self.stroke_cap.unwrap_or_default(), cap: self.stroke_cap.unwrap_or_default(),
join: self.stroke_join.unwrap_or_default(), join: self.stroke_join.unwrap_or_default(),
join_miter_limit: self.miter_limit.unwrap_or(4.), join_miter_limit: self.miter_limit.unwrap_or(4.),
paint_order: self.paint_order.unwrap_or_default(),
dash_lengths: self.effective_dash_lengths(), dash_lengths: self.effective_dash_lengths(),
dash_offset: self.dash_offset.unwrap_or(0.), dash_offset: self.dash_offset.unwrap_or(0.),
transform: glam::DAffine2::IDENTITY, transform: glam::DAffine2::IDENTITY,
}; };
responses.add(GraphOperationMessage::StrokeSet { layer, color, stroke }); responses.add(GraphOperationMessage::StrokeSet { layer, color, stroke });
} }
/// Queues the paint order rewrite for a freshly created `layer`. The order is the relative position of the
/// Stroke and Fill nodes in the chain, so this must run after both the stroke and fill have been applied.
pub fn apply_stroke_order_to_new_layer(&self, layer: LayerNodeIdentifier, responses: &mut VecDeque<Message>) {
let paint_order = self.paint_order.unwrap_or_default();
if paint_order != PaintOrder::default() {
responses.add(GraphOperationMessage::StrokeOrderSet { layer, paint_order });
}
}
} }
/// Builds a `FillChoice::Solid` from a color. /// Builds a `FillChoice::Solid` from a color.
@@ -304,12 +316,23 @@ pub fn sync_drawing_state(drawing: &mut DrawingToolState, natural_fill_enabled:
/// Reads the stroke proto-node inputs (align, cap, join, miter limit, paint order, dash lengths, dash offset) across the selection and updates /// Reads the stroke proto-node inputs (align, cap, join, miter limit, paint order, dash lengths, dash offset) across the selection and updates
/// the matching fields on `drawing`. Each field becomes `None` (mixed) when selected strokes disagree. With no selection, fields are left as-is. /// the matching fields on `drawing`. Each field becomes `None` (mixed) when selected strokes disagree. With no selection, fields are left as-is.
fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessageHandler) -> bool { fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessageHandler) -> bool {
let strokes: Vec<_> = graph_modification_utils::paintable_selected_layers(document) let layers = graph_modification_utils::paintable_selected_layers(document);
// The order radio only swaps a layer's lone adjacent Fill/Stroke pair, so it grays out when no selected
// layer has one; with nothing selected it still sets the default order for new shapes
let paint_order_applicable = layers.is_empty() || layers.iter().any(|&layer| graph_modification_utils::stroke_paint_order_applicable(layer, &document.network_interface));
let mut applicability_changed = false;
if drawing.paint_order_applicable != paint_order_applicable {
drawing.paint_order_applicable = paint_order_applicable;
applicability_changed = true;
}
let strokes: Vec<_> = layers
.into_iter() .into_iter()
.filter_map(|layer| graph_modification_utils::get_stroke_options(layer, &document.network_interface)) .filter_map(|layer| graph_modification_utils::get_stroke_options(layer, &document.network_interface))
.collect(); .collect();
if strokes.is_empty() { if strokes.is_empty() {
return false; return applicability_changed;
} }
fn unanimous<T: PartialEq + Clone>(values: impl IntoIterator<Item = T>) -> Option<T> { fn unanimous<T: PartialEq + Clone>(values: impl IntoIterator<Item = T>) -> Option<T> {
@@ -326,7 +349,7 @@ fn sync_stroke_options(drawing: &mut DrawingToolState, document: &DocumentMessag
let new_dash_lengths = unanimous(strokes.iter().map(|s| &s.dash_lengths)).cloned(); let new_dash_lengths = unanimous(strokes.iter().map(|s| &s.dash_lengths)).cloned();
let new_dash_offset = unanimous(strokes.iter().map(|s| s.dash_offset)); let new_dash_offset = unanimous(strokes.iter().map(|s| s.dash_offset));
let mut changed = false; let mut changed = applicability_changed;
if drawing.stroke_align != new_align { if drawing.stroke_align != new_align {
drawing.stroke_align = new_align; drawing.stroke_align = new_align;
@@ -692,10 +692,7 @@ pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNe
Some(TaggedValue::F64(value)) => *value, Some(TaggedValue::F64(value)) => *value,
_ => 4., _ => 4.,
}; };
let paint_order = match parameters.value(stroke::PaintOrderInput) { let paint_order = get_stroke_paint_order(layer, network_interface);
Some(TaggedValue::PaintOrder(value)) => *value,
_ => PaintOrder::default(),
};
let dash_lengths = match parameters.value(stroke::DashPatternInput) { let dash_lengths = match parameters.value(stroke::DashPatternInput) {
Some(TaggedValue::DashPattern(lengths)) => lengths.clone(), Some(TaggedValue::DashPattern(lengths)) => lengths.clone(),
_ => Vec::new(), _ => Vec::new(),
@@ -721,6 +718,67 @@ pub fn get_stroke_id(layer: LayerNodeIdentifier, network_interface: &NodeNetwork
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER)) NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name(&DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER))
} }
/// Whether the paint order swap can act on the layer: exactly one Fill and one Stroke node in its chain,
/// wired directly together. Extra paint nodes make the swap unreliable, since a cover replaced in place
/// keeps the slot in the paint order that its upstream sibling established.
pub fn stroke_paint_order_applicable(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> bool {
let stroke_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let fill_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER);
let mut strokes = Vec::new();
let mut fills = Vec::new();
let node_graph_layer = NodeGraphLayer::new(layer, network_interface);
for node_id in node_graph_layer
.horizontal_layer_flow()
.take_while(|&node_id| node_id == layer.to_node() || !network_interface.is_layer(&node_id, &[]))
{
let reference = network_interface.reference(&node_id, &[]);
if reference.as_ref() == Some(&stroke_reference) {
strokes.push(node_id);
} else if reference.as_ref() == Some(&fill_reference) {
fills.push(node_id);
}
}
let (&[stroke_node_id], &[fill_node_id]) = (strokes.as_slice(), fills.as_slice()) else {
return false;
};
let primary_source = |node_id: NodeId| match network_interface.input_from_connector(&InputConnector::node_at_index(node_id, 0), &[]) {
Some(NodeInput::Node { node_id, output_index: 0, .. }) => Some(*node_id),
_ => None,
};
primary_source(stroke_node_id) == Some(fill_node_id) || primary_source(fill_node_id) == Some(stroke_node_id)
}
/// The paint order of a layer's stroke, read from the chain topology: both the Fill and Stroke nodes append
/// their cover, so the more downstream of the two paints on top, following the painter's algorithm.
pub fn get_stroke_paint_order(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> PaintOrder {
let stroke_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER);
let fill_reference = DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER);
// The flow iterates downstream to upstream, so the first of the pair encountered is the one painting on top
let mut stroke_position = None;
let mut fill_position = None;
let node_graph_layer = NodeGraphLayer::new(layer, network_interface);
for (position, node_id) in node_graph_layer
.horizontal_layer_flow()
.take_while(|&node_id| node_id == layer.to_node() || !network_interface.is_layer(&node_id, &[]))
.enumerate()
{
let reference = network_interface.reference(&node_id, &[]);
if reference.as_ref() == Some(&stroke_reference) {
stroke_position.get_or_insert(position);
} else if reference.as_ref() == Some(&fill_reference) {
fill_position.get_or_insert(position);
}
}
match stroke_position.zip(fill_position) {
Some((stroke_position, fill_position)) if fill_position < stroke_position => PaintOrder::StrokeBelow,
_ => PaintOrder::StrokeAbove,
}
}
/// Writes the weight back to every selected non-artboard layer's stroke. Layers with an existing stroke just have their /// Writes the weight back to every selected non-artboard layer's stroke. Layers with an existing stroke just have their
/// `WeightInput` updated; layers without one get a fresh stroke node added (defaulting to a black stroke with the new /// `WeightInput` updated; layers without one get a fresh stroke node added (defaulting to a black stroke with the new
/// weight) only when the new weight is nonzero, so changing back to 0 doesn't keep adding empty strokes. /// weight) only when the new weight is nonzero, so changing back to 0 doesn't keep adding empty strokes.
@@ -64,7 +64,9 @@ where
if has_dash { if has_dash {
rows.push(LayoutGroup::row(dash_offset_row(drawing.dash_offset, to_message.clone()))); rows.push(LayoutGroup::row(dash_offset_row(drawing.dash_offset, to_message.clone())));
} }
rows.push(LayoutGroup::row(enum_radio_row::<PaintOrder, _>("Order", drawing.paint_order, false, { // An inapplicable order (no Fill/Stroke pair to reorder) grays out and highlights no entry, since the synced value is only a fallback
let paint_order = drawing.paint_order.filter(|_| drawing.paint_order_applicable);
rows.push(LayoutGroup::row(enum_radio_row::<PaintOrder, _>("Order", paint_order, !drawing.paint_order_applicable, {
let to_message = to_message.clone(); let to_message = to_message.clone();
move |value| to_message(StrokeOptionsUpdate::PaintOrder(value)) move |value| to_message(StrokeOptionsUpdate::PaintOrder(value))
}))); })));
@@ -198,7 +200,12 @@ pub fn apply_miter_limit(drawing: &mut DrawingToolState, limit: f64, document: &
pub fn apply_paint_order(drawing: &mut DrawingToolState, order: PaintOrder, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) { pub fn apply_paint_order(drawing: &mut DrawingToolState, order: PaintOrder, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
drawing.paint_order = Some(order); drawing.paint_order = Some(order);
graph_modification_utils::set_parameter_for_selected_layers(document, graphene_std::vector::stroke::PaintOrderInput, TaggedValue::PaintOrder(order), responses); // A mixed selection only grays the radio out when no layer can take the swap, so skip the ones that can't
for layer in graph_modification_utils::paintable_selected_layers(document) {
if graph_modification_utils::stroke_paint_order_applicable(layer, &document.network_interface) {
responses.add(GraphOperationMessage::StrokeOrderSet { layer, paint_order: order });
}
}
} }
pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec<f64>, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) { pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec<f64>, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
@@ -216,6 +216,7 @@ impl ToolTransition for FreehandTool {
overlay_provider: Some(|context: OverlayContext| FreehandToolMessage::Overlays { context }.into()), overlay_provider: Some(|context: OverlayContext| FreehandToolMessage::Overlays { context }.into()),
tool_abort: Some(FreehandToolMessage::Abort.into()), tool_abort: Some(FreehandToolMessage::Abort.into()),
selection_changed: Some(FreehandToolMessage::SelectionChanged.into()), selection_changed: Some(FreehandToolMessage::SelectionChanged.into()),
graph_changed: Some(FreehandToolMessage::SelectionChanged.into()),
working_color_changed: Some(FreehandToolMessage::WorkingColorChanged.into()), working_color_changed: Some(FreehandToolMessage::WorkingColorChanged.into()),
..Default::default() ..Default::default()
} }
@@ -302,8 +303,9 @@ impl Fsm for FreehandToolFsmState {
let nodes = vec![(NodeId(0), node)]; let nodes = vec![(NodeId(0), node)];
let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.fill.apply_fill(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.apply_stroke_order_to_new_layer(layer, responses);
tool_data.layer = Some(layer); tool_data.layer = Some(layer);
tool_data.new_layer_viewport_start = Some(input.mouse.position); tool_data.new_layer_viewport_start = Some(input.mouse.position);
@@ -314,6 +314,7 @@ impl ToolTransition for PenTool {
EventToMessageMap { EventToMessageMap {
tool_abort: Some(PenToolMessage::Abort.into()), tool_abort: Some(PenToolMessage::Abort.into()),
selection_changed: Some(PenToolMessage::SelectionChanged.into()), selection_changed: Some(PenToolMessage::SelectionChanged.into()),
graph_changed: Some(PenToolMessage::SelectionChanged.into()),
working_color_changed: Some(PenToolMessage::WorkingColorChanged.into()), working_color_changed: Some(PenToolMessage::WorkingColorChanged.into()),
overlay_provider: Some(|context| PenToolMessage::Overlays { context }.into()), overlay_provider: Some(|context| PenToolMessage::Overlays { context }.into()),
..Default::default() ..Default::default()
@@ -1352,8 +1353,9 @@ impl PenToolData {
let parent = document.new_layer_bounding_artboard(input, viewport); let parent = document.new_layer_bounding_artboard(input, viewport);
let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses);
self.current_layer = Some(layer); self.current_layer = Some(layer);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.fill.apply_fill(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.apply_stroke_order_to_new_layer(layer, responses);
self.prior_segment = None; self.prior_segment = None;
self.prior_segments = None; self.prior_segments = None;
responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] }); responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![layer.to_node()] });
@@ -448,6 +448,7 @@ impl ToolTransition for SelectTool {
EventToMessageMap { EventToMessageMap {
tool_abort: Some(SelectToolMessage::Abort.into()), tool_abort: Some(SelectToolMessage::Abort.into()),
selection_changed: Some(SelectToolMessage::SelectionChanged.into()), selection_changed: Some(SelectToolMessage::SelectionChanged.into()),
graph_changed: Some(SelectToolMessage::SelectionChanged.into()),
working_color_changed: Some(SelectToolMessage::WorkingColorChanged.into()), working_color_changed: Some(SelectToolMessage::WorkingColorChanged.into()),
overlay_provider: Some(|context| SelectToolMessage::Overlays { context }.into()), overlay_provider: Some(|context| SelectToolMessage::Overlays { context }.into()),
..Default::default() ..Default::default()
@@ -710,6 +710,7 @@ impl ToolTransition for ShapeTool {
overlay_provider: Some(|context| ShapeToolMessage::Overlays { context }.into()), overlay_provider: Some(|context| ShapeToolMessage::Overlays { context }.into()),
tool_abort: Some(ShapeToolMessage::Abort.into()), tool_abort: Some(ShapeToolMessage::Abort.into()),
selection_changed: Some(ShapeToolMessage::SelectionChanged.into()), selection_changed: Some(ShapeToolMessage::SelectionChanged.into()),
graph_changed: Some(ShapeToolMessage::SelectionChanged.into()),
working_color_changed: Some(ShapeToolMessage::WorkingColorChanged.into()), working_color_changed: Some(ShapeToolMessage::WorkingColorChanged.into()),
..Default::default() ..Default::default()
} }
@@ -1129,8 +1130,9 @@ impl Fsm for ShapeToolFsmState {
skip_rerender: false, skip_rerender: false,
}); });
tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses);
tool_options.drawing.fill.apply_fill(layer, defered_responses); tool_options.drawing.fill.apply_fill(layer, defered_responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses);
tool_options.drawing.apply_stroke_order_to_new_layer(layer, defered_responses);
} }
ShapeType::Arrow => { ShapeType::Arrow => {
let viewport_drag_start = tool_data.data.viewport_drag_start(document); let viewport_drag_start = tool_data.data.viewport_drag_start(document);
@@ -1143,8 +1145,9 @@ impl Fsm for ShapeToolFsmState {
tool_data.line_data.weight = tool_options.drawing.effective_line_weight(); tool_data.line_data.weight = tool_options.drawing.effective_line_weight();
tool_data.line_data.editing_layer = Some(layer); tool_data.line_data.editing_layer = Some(layer);
tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses);
tool_options.drawing.fill.apply_fill(layer, defered_responses); tool_options.drawing.fill.apply_fill(layer, defered_responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, defered_responses);
tool_options.drawing.apply_stroke_order_to_new_layer(layer, defered_responses);
} }
ShapeType::Line => { ShapeType::Line => {
let viewport_drag_start = tool_data.data.viewport_drag_start(document); let viewport_drag_start = tool_data.data.viewport_drag_start(document);
@@ -233,6 +233,7 @@ impl ToolTransition for SplineTool {
canvas_transformed: Some(SplineToolMessage::CanvasTransformed.into()), canvas_transformed: Some(SplineToolMessage::CanvasTransformed.into()),
tool_abort: Some(SplineToolMessage::Abort.into()), tool_abort: Some(SplineToolMessage::Abort.into()),
selection_changed: Some(SplineToolMessage::SelectionChanged.into()), selection_changed: Some(SplineToolMessage::SelectionChanged.into()),
graph_changed: Some(SplineToolMessage::SelectionChanged.into()),
working_color_changed: Some(SplineToolMessage::WorkingColorChanged.into()), working_color_changed: Some(SplineToolMessage::WorkingColorChanged.into()),
} }
} }
@@ -407,8 +408,9 @@ impl Fsm for SplineToolFsmState {
let nodes = vec![(NodeId(1), path_node), (NodeId(0), spline_node)]; let nodes = vec![(NodeId(1), path_node), (NodeId(0), spline_node)];
let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses); let layer = graph_modification_utils::new_custom(NodeId::new(), nodes, parent, responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.fill.apply_fill(layer, responses); tool_options.drawing.fill.apply_fill(layer, responses);
tool_options.drawing.apply_stroke_to_new_layer(layer, responses);
tool_options.drawing.apply_stroke_order_to_new_layer(layer, responses);
tool_data.current_layer = Some(layer); tool_data.current_layer = Some(layer);
tool_data.new_layer_viewport_start = Some(viewport_vec); tool_data.new_layer_viewport_start = Some(viewport_vec);
@@ -403,6 +403,7 @@ impl ToolTransition for TextTool {
EventToMessageMap { EventToMessageMap {
canvas_transformed: None, canvas_transformed: None,
selection_changed: Some(TextToolMessage::SelectionChanged.into()), selection_changed: Some(TextToolMessage::SelectionChanged.into()),
graph_changed: None,
tool_abort: Some(TextToolMessage::Abort.into()), tool_abort: Some(TextToolMessage::Abort.into()),
working_color_changed: Some(TextToolMessage::WorkingColorChanged.into()), working_color_changed: Some(TextToolMessage::WorkingColorChanged.into()),
overlay_provider: Some(|context| TextToolMessage::Overlays { context }.into()), overlay_provider: Some(|context| TextToolMessage::Overlays { context }.into()),
@@ -156,6 +156,9 @@ impl DocumentToolData {
pub struct EventToMessageMap { pub struct EventToMessageMap {
pub canvas_transformed: Option<ToolMessage>, pub canvas_transformed: Option<ToolMessage>,
pub selection_changed: Option<ToolMessage>, pub selection_changed: Option<ToolMessage>,
/// Tools whose control bar mirrors the selection's node chains map this to the same message as
/// `selection_changed`, so a graph edit re-syncs the widgets the same way reselecting would.
pub graph_changed: Option<ToolMessage>,
pub tool_abort: Option<ToolMessage>, pub tool_abort: Option<ToolMessage>,
pub working_color_changed: Option<ToolMessage>, pub working_color_changed: Option<ToolMessage>,
pub overlay_provider: Option<OverlayProvider>, pub overlay_provider: Option<OverlayProvider>,
@@ -178,6 +181,7 @@ pub trait ToolTransition {
subscribe_message(event_to_tool_map.canvas_transformed, EventMessage::CanvasTransformed); subscribe_message(event_to_tool_map.canvas_transformed, EventMessage::CanvasTransformed);
subscribe_message(event_to_tool_map.tool_abort, EventMessage::ToolAbort); subscribe_message(event_to_tool_map.tool_abort, EventMessage::ToolAbort);
subscribe_message(event_to_tool_map.selection_changed, EventMessage::SelectionChanged); subscribe_message(event_to_tool_map.selection_changed, EventMessage::SelectionChanged);
subscribe_message(event_to_tool_map.graph_changed, EventMessage::GraphChanged);
subscribe_message(event_to_tool_map.working_color_changed, EventMessage::WorkingColorChanged); subscribe_message(event_to_tool_map.working_color_changed, EventMessage::WorkingColorChanged);
if let Some(overlay_provider) = event_to_tool_map.overlay_provider { if let Some(overlay_provider) = event_to_tool_map.overlay_provider {
responses.add(OverlaysMessage::AddProvider { provider: overlay_provider }); responses.add(OverlaysMessage::AddProvider { provider: overlay_provider });
@@ -198,6 +202,7 @@ pub trait ToolTransition {
unsubscribe_message(event_to_tool_map.canvas_transformed, EventMessage::CanvasTransformed); unsubscribe_message(event_to_tool_map.canvas_transformed, EventMessage::CanvasTransformed);
unsubscribe_message(event_to_tool_map.tool_abort, EventMessage::ToolAbort); unsubscribe_message(event_to_tool_map.tool_abort, EventMessage::ToolAbort);
unsubscribe_message(event_to_tool_map.selection_changed, EventMessage::SelectionChanged); unsubscribe_message(event_to_tool_map.selection_changed, EventMessage::SelectionChanged);
unsubscribe_message(event_to_tool_map.graph_changed, EventMessage::GraphChanged);
unsubscribe_message(event_to_tool_map.working_color_changed, EventMessage::WorkingColorChanged); unsubscribe_message(event_to_tool_map.working_color_changed, EventMessage::WorkingColorChanged);
if let Some(overlay_provider) = event_to_tool_map.overlay_provider { if let Some(overlay_provider) = event_to_tool_map.overlay_provider {
responses.add(OverlaysMessage::RemoveProvider { provider: overlay_provider }); responses.add(OverlaysMessage::RemoveProvider { provider: overlay_provider });
+2 -4
View File
@@ -697,8 +697,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;
@@ -713,8 +712,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);
+2 -2
View File
@@ -550,7 +550,6 @@ tagged_value! {
#[serde(alias = "LineJoin")] #[serde(alias = "LineJoin")]
StrokeJoin(vector::style::StrokeJoin), StrokeJoin(vector::style::StrokeJoin),
StrokeAlign(vector::style::StrokeAlign), StrokeAlign(vector::style::StrokeAlign),
PaintOrder(vector::style::PaintOrder),
#[serde(alias = "GradientType")] // TODO: Eventually remove this document upgrade code #[serde(alias = "GradientType")] // TODO: Eventually remove this document upgrade code
GradientForm(vector::style::GradientForm), GradientForm(vector::style::GradientForm),
#[serde(alias = "GradientSpreadMethod")] // TODO: Eventually remove this document upgrade code #[serde(alias = "GradientSpreadMethod")] // TODO: Eventually remove this document upgrade code
@@ -564,8 +563,9 @@ tagged_value! {
TextAlign(text_nodes::TextAlign), TextAlign(text_nodes::TextAlign),
ScaleType(core_types::transform::ScaleType), ScaleType(core_types::transform::ScaleType),
// Legacy // Legacy
PaintOrder(vector::style::PaintOrder), // TODO: Eventually remove this document upgrade code
#[serde(alias = "Fill")] #[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill), LegacyFill(graphic_types::migrations::legacy::LegacyFill), // TODO: Eventually remove this document upgrade code
} }
impl TaggedValue { impl TaggedValue {
@@ -24,7 +24,7 @@ use graphene_std::transform::{Footprint, ReferencePoint, ScaleType};
use graphene_std::vector::misc::{ use graphene_std::vector::misc::{
ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType, ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
}; };
use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::vector::style::{DashPattern, GradientForm, GradientHueDirection, GradientInterpolation, GradientSpace, GradientSpread, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification}; use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector, VectorModification};
use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future}; use graphene_std::{Artboard, Context, Graphic, NodeIO, NodeIOTypes, ProtoNodeIdentifier, concrete, fn_type_fut, future};
use node_registry_macros::async_node; use node_registry_macros::async_node;
@@ -336,7 +336,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
StrokeJoin, StrokeJoin,
StrokeAlign, StrokeAlign,
StrokeCap, StrokeCap,
PaintOrder,
GradientForm, GradientForm,
GradientSpread, GradientSpread,
GradientSpace, GradientSpace,
+25 -4
View File
@@ -77,10 +77,26 @@ pub const ATTR_POSITION: &str = "position";
/// Gradient stop's `f64` midpoint (implicit default `0.5`, linear), a factor from 0 to 1 across the distance to the next /// Gradient stop's `f64` midpoint (implicit default `0.5`, linear), a factor from 0 to 1 across the distance to the next
/// stop, on the `List<Color>` inside a `Gradient`. The final stop's midpoint is ignored if "gradient_cyclic" is false. /// stop, on the `List<Color>` inside a `Gradient`. The final stop's midpoint is ignored if "gradient_cyclic" is false.
pub const ATTR_MIDPOINT: &str = "midpoint"; pub const ATTR_MIDPOINT: &str = "midpoint";
/// Vector graphics object's filled area paint, of type List<T> where T is any graphic type. /// Item's ordered list of paint passes, of type `Appearance`. Earlier coverages paint first, compositing below later ones.
pub const ATTR_FILL: &str = "fill"; pub const ATTR_APPEARANCE: &str = "appearance";
/// Vector graphics object's stroke paint, of type List<T> where T is any graphic type. // TODO: Add a "fill_rule" attribute as a sibling of "paint" on the coverage list (uniform across covers) once a FillRule type ships
pub const ATTR_STROKE: &str = "stroke"; /// Coverage's `List<Graphic>` paint (implicit default empty, painting nothing), on the
/// `List<Coverage>` inside an `Appearance`.
pub const ATTR_PAINT: &str = "paint";
/// Stroke coverage's line thickness (`f64`, implicit default `0.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_WEIGHT: &str = "weight";
/// Stroke coverage's `DashPattern` (implicit default empty, a solid line), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_DASH_PATTERN: &str = "dash_pattern";
/// Stroke coverage's dash phase offset distance (`f64`, implicit default `0.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_DASH_OFFSET: &str = "dash_offset";
/// Stroke coverage's `StrokeCap` (implicit default `Butt`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_CAP: &str = "cap";
/// Stroke coverage's `StrokeJoin` (implicit default `Miter`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_JOIN: &str = "join";
/// Stroke coverage's miter limit threshold (`f64`, implicit default `4.`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_JOIN_MITER_LIMIT: &str = "join_miter_limit";
/// Stroke coverage's `StrokeAlign` (implicit default `Center`), on the `Item<Cover>` inside a `Coverage`.
pub const ATTR_ALIGN: &str = "align";
/// Text item's font size in document-space units (`f64`, implicit default `24.`). /// Text item's font size in document-space units (`f64`, implicit default `24.`).
pub const ATTR_FONT_SIZE: &str = "font_size"; pub const ATTR_FONT_SIZE: &str = "font_size";
/// Text item's font, as a `Resource` of the loaded font file. /// Text item's font, as a `Resource` of the loaded font file.
@@ -678,6 +694,11 @@ impl ItemAttributeValues {
self.0.iter().find_map(|(existing_key, value)| if existing_key == key { Some((**value).as_any()) } else { None }) self.0.iter().find_map(|(existing_key, value)| if existing_key == key { Some((**value).as_any()) } else { None })
} }
/// Returns an iterator over key and type-erased value pairs of all stored attributes, in insertion order.
pub fn iter_any(&self) -> impl Iterator<Item = (&str, &dyn std::any::Any)> {
self.0.iter().map(|(key, value)| (key.as_str(), (**value).as_any()))
}
/// Returns a debug-formatted string representation of the attribute value for the given key, if it exists. /// Returns a debug-formatted string representation of the attribute value for the given key, if it exists.
/// The `overrides` function can provide custom formatting for specific type. /// The `overrides` function can provide custom formatting for specific type.
pub fn display_value(&self, key: &str, overrides: fn(&dyn std::any::Any) -> Option<String>) -> Option<String> { pub fn display_value(&self, key: &str, overrides: fn(&dyn std::any::Any) -> Option<String>) -> Option<String> {
@@ -0,0 +1,387 @@
//! The appearance model: an ordered list of paint passes ("coverages") stored in the `ATTR_APPEARANCE` attribute.
//! Data uniform across all covers (the paint) rides the outer `List<Coverage>` so columnar presence holds,
//! while cover-specific data rides the inner `Item<Cover>`, reusing `ATTR_TRANSFORM` for the stroke-authoring space.
use crate::graphic::{Graphic, is_paint_present};
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_ALIGN, ATTR_APPEARANCE, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_PAINT, ATTR_TRANSFORM, ATTR_WEIGHT, Item, List};
use vector_types::vector::style::{DashPattern, Stroke};
/// The geometry-to-region operator a coverage applies before painting:
/// the interior of the geometry (fill) or the region swept along its outline (stroke).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)]
pub enum Cover {
#[default]
Fill,
Stroke,
}
impl std::fmt::Display for Cover {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fill => write!(f, "Fill"),
Self::Stroke => write!(f, "Stroke"),
}
}
}
/// One paint pass of an [`Appearance`]: a [`Cover`] plus its cover-specific parameters, carried as
/// attributes on the inner item. Attributes for stroke parameters are ignored on fill coverages.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Coverage(pub Item<Cover>);
/// An item's ordered list of paint passes, stored in the `ATTR_APPEARANCE` attribute cell.
/// Earlier coverages paint first, compositing below later ones. Each row's paint is the
/// `ATTR_PAINT` attribute beside it.
///
/// The empty appearance is its elided attribute default form, and the state in which it is
/// replaced by an inherited appearance from an outer level of the cascade.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Appearance(pub List<Coverage>);
/// Where a newly inserted coverage lands in the paint order when no same-cover coverage exists to replace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoverPlacement {
/// The front of the list, painting first (below every existing pass).
Below,
/// The back of the list, painting last (above every existing pass).
Above,
}
impl Coverage {
/// Creates a fill coverage with no parameters beyond its cover.
pub fn new_fill() -> Self {
Self(Item::new_from_element(Cover::Fill))
}
/// Creates a stroke coverage, stamping only the parameters that differ from their implicit defaults.
pub fn new_stroke(stroke: &Stroke) -> Self {
let defaults = Stroke::default();
let mut item = Item::new_from_element(Cover::Stroke);
if stroke.weight != defaults.weight {
item.set_attribute(ATTR_WEIGHT, stroke.weight);
}
if !stroke.dash_lengths.is_empty() {
item.set_attribute(ATTR_DASH_PATTERN, DashPattern::from(stroke.dash_lengths.clone()));
}
if stroke.dash_offset != defaults.dash_offset {
item.set_attribute(ATTR_DASH_OFFSET, stroke.dash_offset);
}
if stroke.cap != defaults.cap {
item.set_attribute(ATTR_CAP, stroke.cap);
}
if stroke.join != defaults.join {
item.set_attribute(ATTR_JOIN, stroke.join);
}
if stroke.join_miter_limit != defaults.join_miter_limit {
item.set_attribute(ATTR_JOIN_MITER_LIMIT, stroke.join_miter_limit);
}
if stroke.align != defaults.align {
item.set_attribute(ATTR_ALIGN, stroke.align);
}
if stroke.transform != defaults.transform {
item.set_attribute(ATTR_TRANSFORM, stroke.transform);
}
Self(item)
}
/// This coverage's cover.
pub fn cover(&self) -> Cover {
*self.0.element()
}
/// Extracts the stroke parameters into a [`Stroke`], falling back to the default for any absent attribute.
/// Dash lengths are clamped to non-negative, matching what rendering accepts.
pub fn stroke_params(&self) -> Stroke {
// A single walk of the attribute pairs instead of one keyed scan per parameter, since this runs per item per render pass
let mut stroke = Stroke::default();
for (key, value) in self.0.attributes().iter_any() {
match key {
ATTR_WEIGHT => stroke.weight = value.downcast_ref().copied().unwrap_or(stroke.weight),
ATTR_DASH_PATTERN => stroke.dash_lengths = value.downcast_ref::<DashPattern>().map(DashPattern::clamped_lengths).unwrap_or(stroke.dash_lengths),
ATTR_DASH_OFFSET => stroke.dash_offset = value.downcast_ref().copied().unwrap_or(stroke.dash_offset),
ATTR_CAP => stroke.cap = value.downcast_ref().copied().unwrap_or(stroke.cap),
ATTR_JOIN => stroke.join = value.downcast_ref().copied().unwrap_or(stroke.join),
ATTR_JOIN_MITER_LIMIT => stroke.join_miter_limit = value.downcast_ref().copied().unwrap_or(stroke.join_miter_limit),
ATTR_ALIGN => stroke.align = value.downcast_ref().copied().unwrap_or(stroke.align),
ATTR_TRANSFORM => stroke.transform = value.downcast_ref().copied().unwrap_or(stroke.transform),
_ => {}
}
}
stroke
}
}
/// Builds an appearance row, eliding the paint attribute when it draws nothing.
fn cover_row(coverage: Coverage, paint: List<Graphic>) -> Item<Coverage> {
let mut row = Item::new_from_element(coverage);
if is_paint_present(&paint) {
row.set_attribute(ATTR_PAINT, paint);
}
row
}
impl Appearance {
/// Creates an appearance holding a single coverage with the given paint.
pub fn new_single(coverage: Coverage, paint: List<Graphic>) -> Self {
Self(List::new_from_item(cover_row(coverage, paint)))
}
/// The number of coverages in this appearance.
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether this appearance holds no coverages, the undeclared state that defers to the cascade.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// This appearance if it declares any coverages, or `None` for the undeclared (empty) state.
pub fn declared(&self) -> Option<&Self> {
(!self.is_empty()).then_some(self)
}
/// Resolves the cascade for one item: its own declared appearance wins, while an undeclared
/// (absent or empty) cell inherits the nearest ancestor's declared appearance.
pub fn cascade<'a>(own: Option<&'a Self>, inherited: Option<&'a Self>) -> Option<&'a Self> {
own.and_then(Self::declared).or(inherited)
}
/// Iterates the coverages in paint order.
pub fn covers(&self) -> impl Iterator<Item = &Coverage> {
self.0.iter_element_values()
}
/// The coverage at the given index in paint order.
pub fn cover_at(&self, index: usize) -> Option<&Coverage> {
self.0.element(index)
}
/// The paint of the coverage at the given index, or `None` if the paint attribute is absent.
pub fn paint_at(&self, index: usize) -> Option<&List<Graphic>> {
self.0.attribute::<List<Graphic>>(ATTR_PAINT, index)
}
/// The index of the first coverage of the given cover in paint order.
pub fn first_index_of(&self, cover: Cover) -> Option<usize> {
self.covers().position(|coverage| coverage.cover() == cover)
}
/// The first coverage of the given cover in paint order.
pub fn first_coverage_of(&self, cover: Cover) -> Option<&Coverage> {
self.first_index_of(cover).and_then(|index| self.cover_at(index))
}
/// The paint of the first coverage of the given cover, filtered to paint that draws something.
pub fn first_paint_of(&self, cover: Cover) -> Option<&List<Graphic>> {
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| is_paint_present(paint))
}
/// Iterates the coverages in paint order together with their paint, which is `None` when absent or drawing nothing.
pub fn covers_with_paints(&self) -> impl Iterator<Item = (&Coverage, Option<&List<Graphic>>)> {
self.covers()
.enumerate()
.map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| is_paint_present(paint))))
}
/// Gathers the renderer's per-item reads in one walk of the coverage list.
pub fn fill_and_stroke(&self) -> FillAndStroke<'_> {
let mut first_fill = None;
let mut first_stroke = None;
for (index, coverage) in self.covers().enumerate() {
match coverage.cover() {
Cover::Fill if first_fill.is_none() => first_fill = Some(index),
Cover::Stroke if first_stroke.is_none() => first_stroke = Some((index, coverage)),
_ => {}
}
}
let painted = |index| self.paint_at(index).filter(|paint| is_paint_present(paint));
FillAndStroke {
stroke: first_stroke.map(|(_, coverage)| coverage.stroke_params()),
fill_paint: first_fill.and_then(painted),
stroke_paint: first_stroke.and_then(|(index, _)| painted(index)),
stroke_below: first_stroke.zip(first_fill).is_some_and(|((stroke_index, _), fill_index)| stroke_index < fill_index),
}
}
/// Whether any coverage of the given cover exists, regardless of whether its paint draws anything.
pub fn has_cover(&self, cover: Cover) -> bool {
self.first_index_of(cover).is_some()
}
/// Whether any coverage of the given cover has paint that draws something, i.e. paint that is
/// present and not empty. A coverage whose paint is [`Graphic::None`] exists but paints nothing.
pub fn has_painted_cover(&self, cover: Cover) -> bool {
self.covers()
.enumerate()
.any(|(index, coverage)| coverage.cover() == cover && self.paint_at(index).is_some_and(is_paint_present))
}
/// Replaces the first coverage of the incoming cover in place (keeping its position in the paint order),
/// or inserts a new row at the requested end of the paint order if none exists.
pub fn replace_or_insert(&mut self, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
if let Some(index) = self.first_index_of(coverage.cover()) {
if let Some(element) = self.0.element_mut(index) {
*element = coverage;
}
self.0.set_attribute(ATTR_PAINT, index, paint);
return;
}
let row = cover_row(coverage, paint);
match placement {
CoverPlacement::Above => self.0.push(row),
CoverPlacement::Below => {
let mut reordered = List::new_from_item(row);
reordered.extend(std::mem::take(&mut self.0));
self.0 = reordered;
}
}
}
/// Sets the paint of the first coverage of the given cover, leaving its other parameters untouched.
/// Returns `false` without changing anything if no coverage of that cover exists.
pub fn set_paint_of(&mut self, cover: Cover, paint: List<Graphic>) -> bool {
let Some(index) = self.first_index_of(cover) else { return false };
self.0.set_attribute(ATTR_PAINT, index, paint);
true
}
/// Discards every coverage that is not of the given cover, preserving the survivors' paint order.
pub fn retain_cover(&mut self, cover: Cover) {
self.0 = std::mem::take(&mut self.0).into_iter().filter(|row| row.element().cover() == cover).collect();
}
}
/// The first fill and stroke of an appearance in the form rendering consumes: the stroke's parameters,
/// each cover's first paint (filtered to paint that draws something), and their relative paint order.
#[derive(Debug, Default)]
pub struct FillAndStroke<'a> {
pub stroke: Option<Stroke>,
pub fill_paint: Option<&'a List<Graphic>>,
pub stroke_paint: Option<&'a List<Graphic>>,
/// Whether the first stroke coverage sits before the first fill in the paint order, painting below it.
pub stroke_below: bool,
}
/// Stamps a coverage into the item's `ATTR_APPEARANCE` cell, creating the attribute if absent.
/// The coverage replaces the first same-cover one in place, or lands at the placement end of the paint order.
pub fn stamp_coverage<T>(item: &mut Item<T>, coverage: Coverage, paint: List<Graphic>, placement: CoverPlacement) {
item.attribute_mut_or_insert_default::<Appearance>(ATTR_APPEARANCE).replace_or_insert(coverage, paint, placement);
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::Color;
use glam::{DAffine2, DVec2};
use vector_types::vector::style::{StrokeAlign, StrokeCap, StrokeJoin};
fn solid_paint(color: Color) -> List<Graphic> {
List::new_from_element(Graphic::Color(List::new_from_element(color)))
}
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
let paint = appearance.paint_at(index)?;
let Some(Graphic::Color(colors)) = paint.element(0) else { return None };
colors.element(0).copied()
}
#[test]
fn stroke_params_survive_the_attribute_round_trip() {
let stroke = Stroke {
weight: 3.,
dash_lengths: vec![4., -2.],
dash_offset: 1.5,
cap: StrokeCap::Round,
join: StrokeJoin::Bevel,
join_miter_limit: 7.,
align: StrokeAlign::Inside,
transform: DAffine2::from_scale(DVec2::new(2., 3.)),
};
let coverage = Coverage::new_stroke(&stroke);
assert_eq!(coverage.cover(), Cover::Stroke);
let extracted = coverage.stroke_params();
assert_eq!(extracted.weight, 3.);
assert_eq!(extracted.dash_lengths, vec![4., 0.], "negative dash lengths should clamp to zero on extraction");
assert_eq!(extracted.dash_offset, 1.5);
assert_eq!(extracted.cap, StrokeCap::Round);
assert_eq!(extracted.join, StrokeJoin::Bevel);
assert_eq!(extracted.join_miter_limit, 7.);
assert_eq!(extracted.align, StrokeAlign::Inside);
assert_eq!(extracted.transform, DAffine2::from_scale(DVec2::new(2., 3.)));
}
#[test]
fn empty_appearance_is_undeclared_and_defers_to_the_cascade() {
let inherited = Appearance::new_single(Coverage::new_fill(), solid_paint(Color::BLACK));
let empty = Appearance::default();
assert!(empty.declared().is_none(), "an empty appearance should be undeclared");
assert!(inherited.declared().is_some(), "an appearance with a coverage should be declared");
assert_eq!(Appearance::cascade(Some(&empty), Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(None, Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&inherited), None), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&empty), None), None);
assert_eq!(Appearance::cascade(None, None), None);
}
#[test]
fn default_valued_stroke_parameters_elide_to_absence() {
let coverage = Coverage::new_stroke(&Stroke::default());
assert_eq!(coverage.0.attributes().keys().count(), 0, "default parameters should stay absent");
assert_eq!(coverage.stroke_params(), Stroke::default(), "absent attributes should read back as the defaults");
let coverage = Coverage::new_stroke(&Stroke::new(2.));
let keys: Vec<_> = coverage.0.attributes().keys().collect();
assert_eq!(keys, vec![ATTR_WEIGHT], "only the non-default weight should be stamped");
assert_eq!(coverage.stroke_params().weight, 2.);
}
#[test]
fn replace_keeps_position_and_the_other_rows_paint() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::BLUE), CoverPlacement::Above);
assert_eq!(appearance.len(), 2, "replacement should not add a row");
assert_eq!(appearance.cover_at(0).map(Coverage::cover), Some(Cover::Fill), "the fill should keep its position");
assert_eq!(paint_color(&appearance, 0), Some(Color::BLUE));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the stroke row's paint should be untouched");
}
#[test]
fn below_insertion_prepends_and_preserves_paint_columns() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Below);
let covers: Vec<_> = appearance.covers().map(Coverage::cover).collect();
assert_eq!(covers, vec![Cover::Fill, Cover::Stroke], "a below-placed fill should paint before the stroke");
assert_eq!(paint_color(&appearance, 0), Some(Color::RED));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the existing row's paint should survive the reorder");
}
#[test]
fn painted_cover_distinguishes_none_paint_from_absence() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), List::new_from_element(Graphic::None), CoverPlacement::Above);
assert!(appearance.has_cover(Cover::Fill), "a none-painted coverage still exists");
assert!(!appearance.has_painted_cover(Cover::Fill), "a none-painted coverage draws nothing");
assert!(!appearance.has_cover(Cover::Stroke));
assert!(!appearance.has_painted_cover(Cover::Stroke));
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
assert!(appearance.has_painted_cover(Cover::Fill));
}
}
+152 -64
View File
@@ -1,13 +1,15 @@
use crate::appearance::{Appearance, Cover, Coverage};
use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash; use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, NodeIdPath}; use core_types::list::{ATTR_APPEARANCE, ATTR_PAINT, Item, ItemAttributeValues, List, NodeIdPath};
use core_types::math::quad::Quad;
use core_types::ops::FromAnchorPosition; use core_types::ops::FromAnchorPosition;
use core_types::render_complexity::RenderComplexity; use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color}; use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color};
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use raster_types::{CPU, GPU, Raster}; use raster_types::{CPU, GPU, Raster};
use std::borrow::Cow;
use vector_types::Gradient; use vector_types::Gradient;
pub use vector_types::Vector; pub use vector_types::Vector;
@@ -119,6 +121,7 @@ pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
&& content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none() && content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY, 0).is_none() && content.attribute::<f64>(ATTR_OPACITY, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none() && content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none()
&& content.attribute::<Appearance>(ATTR_APPEARANCE, 0).is_none()
&& content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).is_none() && content.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, 0).is_none()
} }
@@ -134,13 +137,14 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) { fn flatten_recursive<T>(output: &mut List<T>, current_graphic_list: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) {
for current_graphic_item in current_graphic_list.into_iter() { for current_graphic_item in current_graphic_list.into_iter() {
// Whether the parent carries each attribute: a structural fact (column presence), never a value comparison. // Whether the parent carries each composed attribute: a structural fact (column presence), never a value comparison.
// Flattening composes a parent attribute onto its children only when the parent has it, // Flattening composes a parent attribute onto its children only when the parent has it,
// so an absent parent attribute never invents a column the children didn't already have. // so an absent parent attribute never invents a column the children didn't already have.
let parent_has_transform = current_graphic_item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some(); let parent_has_transform = current_graphic_item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some();
let parent_has_opacity = current_graphic_item.attribute::<f64>(ATTR_OPACITY).is_some(); let parent_has_opacity = current_graphic_item.attribute::<f64>(ATTR_OPACITY).is_some();
let parent_has_fill = current_graphic_item.attribute::<f64>(ATTR_OPACITY_FILL).is_some(); let parent_has_fill = current_graphic_item.attribute::<f64>(ATTR_OPACITY_FILL).is_some();
let parent_has_layer_path = current_graphic_item.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH).is_some(); let parent_has_layer_path = current_graphic_item.attribute::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH).is_some();
let parent_appearance = current_graphic_item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).cloned();
let layer_path: NodeIdPath = current_graphic_item.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH); let layer_path: NodeIdPath = current_graphic_item.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH);
let current_transform: DAffine2 = current_graphic_item.attribute_cloned_or_default(ATTR_TRANSFORM); let current_transform: DAffine2 = current_graphic_item.attribute_cloned_or_default(ATTR_TRANSFORM);
@@ -172,6 +176,14 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
*v *= current_fill; *v *= current_fill;
} }
} }
// Appearance cascades into each child whose own is undeclared, since a declared child wins wholesale
if let Some(appearance) = &parent_appearance {
for v in sub_list.iter_attribute_values_mut_or_default::<Appearance>(ATTR_APPEARANCE) {
if v.is_empty() {
*v = appearance.clone();
}
}
}
flatten_recursive(output, sub_list, extract_variant); flatten_recursive(output, sub_list, extract_variant);
} }
@@ -196,6 +208,11 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
if parent_has_layer_path { if parent_has_layer_path {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone()); item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
} }
if let Some(appearance) = &parent_appearance
&& item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).is_none()
{
item.set_attribute(ATTR_APPEARANCE, appearance.clone());
}
output.push(item); output.push(item);
} }
@@ -216,32 +233,7 @@ pub fn is_paint_present(graphic_list: &List<Graphic>) -> bool {
graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty()) graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())
} }
/// Look up the paint graphics stored under attribute for a vector item, in the canonical `List<Graphic>` form. /// Bake the provided transform into the per-item transforms of the appearance's paint graphics.
pub fn graphic_list_at<'a>(list: &'a List<Vector>, index: usize, attribute: &str) -> Option<Cow<'a, List<Graphic>>> {
list.attribute::<List<Graphic>>(attribute, index)
.map(Cow::Borrowed)
// Treat a blank paint attribute as absent so an empty attribute doesn't count as painted
.filter(|graphic_list| is_paint_present(graphic_list))
}
/// Whether the item carries a non-blank canonical `List<Graphic>` paint attribute,
/// checked by borrowing without cloning the renderable list.
pub fn has_paint_at(list: &List<Vector>, index: usize, attribute: &str) -> bool {
list.attribute::<List<Graphic>>(attribute, index).is_some_and(is_paint_present)
}
/// Stores a paint attribute in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute(attributes: &mut ItemAttributeValues, key: &str, paint: impl IntoGraphicList) {
attributes.insert(key, paint.into_graphic_list());
}
/// Stores a paint attribute at a list index in its canonical `List<Graphic>` form, the only representation paint readers accept.
pub fn set_paint_attribute_at<T>(list: &mut List<T>, index: usize, key: &str, paint: impl IntoGraphicList) {
list.set_attribute(key, index, paint.into_graphic_list());
}
/// Bake the provided transform into the per-item transforms of the paint graphics stored under the
/// canonical `List<Graphic>` fill and stroke attributes.
pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) { pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) {
fn bake_list_transform<T>(list: &mut List<T>, transform: DAffine2) { fn bake_list_transform<T>(list: &mut List<T>, transform: DAffine2) {
for item_transform in list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) { for item_transform in list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
@@ -249,24 +241,26 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
} }
} }
fn bake_graphic_paint_transform(graphics: &mut List<Graphic>, transform: DAffine2) { fn bake_graphic_transform(graphic: &mut Graphic, transform: DAffine2) {
for graphic in graphics.iter_element_values_mut() { match graphic {
match graphic { Graphic::None => {}
Graphic::None => {} Graphic::Graphic(list) => bake_list_transform(list, transform),
Graphic::Graphic(list) => bake_list_transform(list, transform), Graphic::Vector(list) => bake_list_transform(list, transform),
Graphic::Vector(list) => bake_list_transform(list, transform), Graphic::RasterCPU(list) => bake_list_transform(list, transform),
Graphic::RasterCPU(list) => bake_list_transform(list, transform), Graphic::RasterGPU(list) => bake_list_transform(list, transform),
Graphic::RasterGPU(list) => bake_list_transform(list, transform), Graphic::Gradient(list) => bake_list_transform(list, transform),
Graphic::Gradient(list) => bake_list_transform(list, transform), Graphic::Text(list) => bake_list_transform(list, transform),
Graphic::Text(list) => bake_list_transform(list, transform), Graphic::Color(_) => {}
Graphic::Color(_) => {}
}
} }
} }
for paint_key in [ATTR_FILL, ATTR_STROKE] { if let Some(appearance) = attributes.get_mut::<Appearance>(ATTR_APPEARANCE)
if let Some(graphics) = attributes.get_mut::<List<Graphic>>(paint_key) { && let Some(paints) = appearance.0.iter_attribute_values_mut::<List<Graphic>>(ATTR_PAINT)
bake_graphic_paint_transform(graphics, transform); {
for paint in paints {
for graphic in paint.iter_element_values_mut() {
bake_graphic_transform(graphic, transform);
}
} }
} }
} }
@@ -448,15 +442,24 @@ impl Graphic {
pub fn can_reduce_to_clip_path(&self) -> bool { pub fn can_reduce_to_clip_path(&self) -> bool {
match self { match self {
Graphic::Vector(vector) => (0..vector.len()).all(|index| { Graphic::Vector(vector) => (0..vector.len()).all(|index| {
let Some(element) = vector.element(index) else { return false };
let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity: f64 = vector.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let appearance = vector.attribute::<Appearance>(ATTR_APPEARANCE, index);
let fill_opaque_or_absent = graphic_list_at(vector, index, ATTR_FILL).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque())); let fills_opaque_or_absent = appearance.is_none_or(|appearance| {
appearance
.covers_with_paints()
.filter(|(coverage, _)| coverage.cover() == Cover::Fill)
.all(|(_, paint)| paint.is_none_or(|paint| paint.element(0).is_none_or(Graphic::is_opaque)))
});
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) let strokes_invisible_or_transparent = appearance.is_none_or(|appearance| {
|| graphic_list_at(vector, index, ATTR_STROKE).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent())); appearance
.covers_with_paints()
.filter(|(coverage, _)| coverage.cover() == Cover::Stroke)
.all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(|paint| paint.element(0).is_none_or(Graphic::is_fully_transparent)))
});
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent opacity > 1. - f64::EPSILON && fills_opaque_or_absent && strokes_invisible_or_transparent
}), }),
_ => false, _ => false,
} }
@@ -467,16 +470,27 @@ impl Graphic {
Graphic::None => false, Graphic::None => false,
Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque), Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque),
Graphic::Vector(list) => { Graphic::Vector(list) => {
let is_paint_opaque_at = |key: &str, index: usize| graphic_list_at(list, index, key).is_some_and(|graphic_list| graphic_list.element(0).is_some_and(|graphic| graphic.is_opaque()));
!list.is_empty() !list.is_empty()
&& (0..list.len()).all(|i| { && (0..list.len()).all(|i| {
let Some(vector) = list.element(i) else { return false };
let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.); let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.);
let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.); let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.);
let fill_opaque = opacity_fill >= 1. - f64::EPSILON && is_paint_opaque_at(ATTR_FILL, i); let appearance = list.attribute::<Appearance>(ATTR_APPEARANCE, i);
let stroke_opaque_or_invisible = vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_paint_opaque_at(ATTR_STROKE, i);
opacity >= 1. - f64::EPSILON && fill_opaque && stroke_opaque_or_invisible let fill_opaque = opacity_fill >= 1. - f64::EPSILON
&& appearance.is_some_and(|appearance| {
appearance
.covers_with_paints()
.any(|(coverage, paint)| coverage.cover() == Cover::Fill && paint.is_some_and(|paint| paint.element(0).is_some_and(Graphic::is_opaque)))
});
let strokes_opaque_or_invisible = appearance.is_none_or(|appearance| {
appearance
.covers_with_paints()
.filter(|(coverage, _)| coverage.cover() == Cover::Stroke)
.all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_some_and(|paint| paint.element(0).is_some_and(Graphic::is_opaque)))
});
opacity >= 1. - f64::EPSILON && fill_opaque && strokes_opaque_or_invisible
}) })
} }
Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()), Graphic::Color(list) => list.element(0).is_some_and(|color| color.is_opaque()),
@@ -490,18 +504,29 @@ impl Graphic {
Graphic::None => true, Graphic::None => true,
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent), Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
Graphic::Vector(list) => (0..list.len()).all(|i| { Graphic::Vector(list) => (0..list.len()).all(|i| {
let Some(vector) = list.element(i) else { return false };
let is_paint_fully_transparent_at =
|key: &str, index: usize| graphic_list_at(list, index, key).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.); let opacity: f64 = list.attribute_cloned_or(ATTR_OPACITY, i, 1.);
if opacity <= f64::EPSILON { if opacity <= f64::EPSILON {
return true; return true;
} }
let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.); let opacity_fill: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, i, 1.);
let fill_invisible = opacity_fill <= f64::EPSILON || is_paint_fully_transparent_at(ATTR_FILL, i); let appearance = list.attribute::<Appearance>(ATTR_APPEARANCE, i);
let stroke_invisible = vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()) || is_paint_fully_transparent_at(ATTR_STROKE, i);
fill_invisible && stroke_invisible let fills_invisible = opacity_fill <= f64::EPSILON
|| appearance.is_none_or(|appearance| {
appearance
.covers_with_paints()
.filter(|(coverage, _)| coverage.cover() == Cover::Fill)
.all(|(_, paint)| paint.is_none_or(|paint| paint.element(0).is_none_or(Graphic::is_fully_transparent)))
});
let strokes_invisible = appearance.is_none_or(|appearance| {
appearance
.covers_with_paints()
.filter(|(coverage, _)| coverage.cover() == Cover::Stroke)
.all(|(coverage, paint)| !coverage.stroke_params().has_renderable_stroke() || paint.is_none_or(|paint| paint.element(0).is_none_or(Graphic::is_fully_transparent)))
});
fills_invisible && strokes_invisible
}), }),
Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.), Graphic::Color(list) => list.iter_element_values().all(|color| color.a() == 0.),
Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)), Graphic::Gradient(list) => list.iter_element_values().all(|stops| stops.iter().all(|stop| stop.color.a() == 0.)),
@@ -530,11 +555,47 @@ impl Graphic {
} }
} }
/// Combined bounding box of a vector list's rows, inflating each row by its appearance's stroke when `include_stroke`.
/// Stroke parameters live on the row attribute, out of reach of the element-level impl.
pub fn vector_list_bounding_box(list: &List<Vector>, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
let mut combined_bounds: Option<[DVec2; 2]> = None;
for index in 0..list.len() {
let Some(element) = list.element(index) else { continue };
let item_transform: DAffine2 = list.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let row_transform = transform * item_transform;
let Some(mut bounds) = element.bounding_box_with_transform(row_transform) else { continue };
// The full line width (not half) accounts for different styles of stroke caps
if include_stroke
&& let Some(stroke) = list
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.and_then(|appearance| appearance.first_coverage_of(Cover::Stroke))
.map(Coverage::stroke_params)
{
let scale = row_transform.scale_magnitudes();
let offset = DVec2::splat(stroke.weight() * scale.x.max(scale.y) * stroke.join_miter_limit);
bounds = [bounds[0] - offset, bounds[1] + offset];
}
combined_bounds = Some(match combined_bounds {
Some(existing) => Quad::combine_bounds(existing, bounds),
None => bounds,
});
}
match combined_bounds {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
impl BoundingBox for Graphic { impl BoundingBox for Graphic {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self { match self {
Graphic::None => RenderBoundingBox::None, Graphic::None => RenderBoundingBox::None,
Graphic::Vector(list) => list.bounding_box(transform, include_stroke), Graphic::Vector(list) => vector_list_bounding_box(list, transform, include_stroke),
Graphic::RasterCPU(list) => list.bounding_box(transform, include_stroke), Graphic::RasterCPU(list) => list.bounding_box(transform, include_stroke),
Graphic::RasterGPU(list) => list.bounding_box(transform, include_stroke), Graphic::RasterGPU(list) => list.bounding_box(transform, include_stroke),
Graphic::Graphic(list) => list.bounding_box(transform, include_stroke), Graphic::Graphic(list) => list.bounding_box(transform, include_stroke),
@@ -547,7 +608,7 @@ impl BoundingBox for Graphic {
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox { fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self { match self {
Graphic::None => RenderBoundingBox::None, Graphic::None => RenderBoundingBox::None,
Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke), Graphic::Vector(vector) => vector_list_bounding_box(vector, transform, include_stroke),
Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke), Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke), Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke), Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
@@ -717,6 +778,33 @@ mod tests {
let flattened: List<Vector> = group.into_flattened_list(); let flattened: List<Vector> = group.into_flattened_list();
assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5); assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5);
} }
// A padded (empty) appearance cell is undeclared, so the parent's appearance cascades into it while a declared sibling keeps its own
#[test]
fn flatten_cascades_into_padded_empty_appearance_rows() {
use core_types::Color;
let solid = |color: Color| List::new_from_element(Graphic::Color(List::new_from_element(color)));
// Declaring an appearance on row 0 forces the column, padding row 1 with the empty appearance
let mut inner = List::new();
inner.push(Item::new_from_element(Vector::default()));
inner.push(Item::new_from_element(Vector::default()));
inner.set_attribute(ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), solid(Color::BLACK)));
let mut outer = List::new_from_element(Graphic::Vector(inner));
outer.set_attribute(ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), solid(Color::WHITE)));
let flattened: List<Vector> = outer.into_flattened_list();
let color_of = |index: usize| {
let appearance = flattened.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Some(Graphic::Color(colors)) = appearance.paint_at(0)?.element(0) else { return None };
colors.element(0).copied()
};
assert_eq!(color_of(0), Some(Color::BLACK), "a declared row should keep its own appearance");
assert_eq!(color_of(1), Some(Color::WHITE), "a padded row should inherit the parent appearance");
}
} }
#[cfg(test)] #[cfg(test)]
+23 -11
View File
@@ -1,3 +1,4 @@
pub mod appearance;
pub mod artboard; pub mod artboard;
pub mod graphic; pub mod graphic;
@@ -7,6 +8,7 @@ pub use raster_types;
pub use vector_types; pub use vector_types;
// Re-export commonly used types at the crate root // Re-export commonly used types at the crate root
pub use appearance::{Appearance, Cover, CoverPlacement, Coverage, FillAndStroke, stamp_coverage};
pub use artboard::Artboard; pub use artboard::Artboard;
pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector}; pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector};
@@ -92,10 +94,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 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>,
} }
@@ -103,6 +106,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,
@@ -135,7 +139,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,
@@ -186,10 +189,15 @@ pub mod migrations {
use super::*; use super::*;
use vector_types::vector::style::Stroke; use vector_types::vector::style::Stroke;
/// The legacy `style` payload (including its stroke) must still parse so the untagged format
/// disambiguation succeeds, even though the geometry is all that survives.
#[test] #[test]
fn preserves_stroke_from_old_vector_data_style() { fn recovers_geometry_from_old_vector_data_with_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()
}; };
@@ -202,22 +210,26 @@ pub mod migrations {
.as_object_mut() .as_object_mut()
.unwrap() .unwrap()
.insert("fill".into(), serde_json::to_value(legacy::LegacyFill::default()).unwrap()); .insert("fill".into(), serde_json::to_value(legacy::LegacyFill::default()).unwrap());
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.); let migrated = migrate_to_optional_vector(value).unwrap().expect("the legacy shape should parse into a vector");
assert_eq!(
migrated.point_domain.positions(),
[glam::DVec2::new(3., 4.)],
"the legacy geometry should survive 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.)]);
} }
} }
} }
@@ -7,7 +7,7 @@ use core_types::{ATTR_GRADIENT_FORM, ATTR_TRANSFORM, Color};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use graphic_types::Graphic; use graphic_types::Graphic;
use graphic_types::vector_types::gradient::GradientForm; use graphic_types::vector_types::gradient::GradientForm;
use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::vector_types::vector::style::{Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use std::fmt::Write; use std::fmt::Write;
use vector_types::Gradient; use vector_types::Gradient;
use vector_types::gradient::{GradientSettings, GradientSpread}; use vector_types::gradient::{GradientSettings, GradientSpread};
@@ -194,7 +194,6 @@ impl RenderExt for Stroke {
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join); let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit); let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align); let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes // Render the needed stroke attributes
let mut attributes = String::new(); let mut attributes = String::new();
@@ -219,7 +218,7 @@ impl RenderExt for Stroke {
if let Some(stroke_join_miter_limit) = stroke_join_miter_limit { if let Some(stroke_join_miter_limit) = stroke_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{stroke_join_miter_limit}""#); let _ = write!(&mut attributes, r#" stroke-miterlimit="{stroke_join_miter_limit}""#);
} }
if paint_order.is_some() { if render_params.stroke_below {
let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#); let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#);
} }
attributes attributes
+183 -128
View File
@@ -7,7 +7,8 @@ use core_types::bounds::RenderBoundingBox;
use core_types::color::Color; use core_types::color::Color;
use core_types::color::SRGBA8; use core_types::color::SRGBA8;
use core_types::consts::DEFAULT_FONT_SIZE; use core_types::consts::DEFAULT_FONT_SIZE;
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, List, NodeIdPath}; use core_types::list::ATTR_APPEARANCE;
use core_types::list::{Item, List, NodeIdPath};
use core_types::math::quad::Quad; use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity; use core_types::render_complexity::RenderComplexity;
use core_types::transform::Footprint; use core_types::transform::Footprint;
@@ -21,13 +22,12 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2}; use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper; use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource; use graphene_resource::Resource;
use graphic_types::graphic::{graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture}; use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm}; use graphic_types::vector_types::gradient::{Gradient, GradientForm};
use graphic_types::vector_types::subpath::Subpath; use graphic_types::vector_types::subpath::Subpath;
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint}; use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::style::{PaintOrder, RenderMode, StrokeAlign, StrokeCap, StrokeJoin}; use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector}; use graphic_types::{Appearance, Artboard, Cover, Coverage, FillAndStroke, Graphic, Vector};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts}; use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero; use num_traits::Zero;
use skrifa::instance::{LocationRef, NormalizedCoord, Size}; use skrifa::instance::{LocationRef, NormalizedCoord, Size};
@@ -224,28 +224,45 @@ pub struct RenderParams {
/// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks /// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks
pub alignment_parent_transform: Option<DAffine2>, pub alignment_parent_transform: Option<DAffine2>,
pub aligned_strokes: bool, pub aligned_strokes: bool,
pub override_paint_order: bool, /// Paint the stroke below the fill within the same SVG path element
pub stroke_below: bool,
/// Are we rendering for a pattern content /// Are we rendering for a pattern content
pub inside_pattern: bool, pub inside_pattern: bool,
pub artboard_background: Option<Color>, pub artboard_background: Option<Color>,
/// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode. /// Viewport zoom level (document-space scale). Used to compute constant viewport-pixel stroke widths in Outline mode.
pub viewport_zoom: f64, pub viewport_zoom: f64,
/// The nearest ancestor's appearance, cascading to items that lack their own.
pub inherited_appearance: Option<Appearance>,
} }
impl RenderParams { impl RenderParams {
pub fn for_clipper(&self) -> Self { pub fn for_clipper(&self) -> Self {
Self { for_mask: true, ..*self } Self { for_mask: true, ..self.clone() }
} }
pub fn for_alignment(&self, transform: DAffine2) -> Self { pub fn for_alignment(&self, transform: DAffine2) -> Self {
Self { Self {
alignment_parent_transform: Some(transform), alignment_parent_transform: Some(transform),
..*self ..self.clone()
} }
} }
pub fn for_pattern(&self) -> Self { pub fn for_pattern(&self) -> Self {
Self { inside_pattern: true, ..*self } // A paint subtree supplies its own styling, so the painted element's appearance must not cascade into it
Self {
inside_pattern: true,
inherited_appearance: None,
..self.clone()
}
}
/// Params for rendering a child item, cascading this item's appearance to descendants lacking their own.
/// Callers only build these when the item carries a declared appearance, so an item without one clones nothing.
pub fn for_child_item(&self, item_appearance: &Appearance) -> Self {
Self {
inherited_appearance: Some(item_appearance.clone()),
..self.clone()
}
} }
pub fn to_canvas(&self) -> bool { pub fn to_canvas(&self) -> bool {
@@ -552,12 +569,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 `ATTR_FILL` row attribute, exposed so message handlers can read it. /// Per-layer `ATTR_APPEARANCE` row attribute, exposed so message handlers can read it.
#[cfg_attr(feature = "serde", serde(skip))] #[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic>>>, pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
/// Per-layer `ATTR_STROKE` row attribute, exposed so message handlers can read it.
#[cfg_attr(feature = "serde", serde(skip))]
pub stroke_attributes: HashMap<NodeId, Arc<List<Graphic>>>,
pub backgrounds: Vec<Background>, pub backgrounds: Vec<Background>,
} }
@@ -581,8 +595,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());
@@ -593,8 +606,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 {
@@ -618,18 +630,19 @@ pub trait Render: BoundingBox + RenderComplexity {
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, _render_params: &RenderParams); fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, _render_params: &RenderParams);
/// The upstream click targets for each layer are collected during the render so that they do not have to be calculated for each click detection. /// The upstream click targets for each layer are collected during the render so that they do not have to be calculated for each click detection.
fn add_upstream_click_targets(&self, _click_targets: &mut Vec<ClickTarget>) {} /// `inherited_appearance` is the nearest ancestor's appearance, cascading to items that lack their own, mirroring the render cascade.
fn add_upstream_click_targets(&self, _click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {}
/// Like `add_upstream_click_targets` but for visual outlines. `List<Vector>` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry. /// Like `add_upstream_click_targets` but for visual outlines. `List<Vector>` overrides this to ignore `editor:click_target` so outlines reflect the actual geometry.
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) { fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
self.add_upstream_click_targets(outlines); self.add_upstream_click_targets(outlines, inherited_appearance);
} }
// TODO: Store all click targets in a vec which contains the AABB, click target, and path // TODO: Store all click targets in a vec which contains the AABB, click target, and path
// fn add_click_targets(&self, click_targets: &mut Vec<([DVec2; 2], ClickTarget, Vec<NodeId>)>, current_path: Option<NodeId>) {} // fn add_click_targets(&self, click_targets: &mut Vec<([DVec2; 2], ClickTarget, Vec<NodeId>)>, current_path: Option<NodeId>) {}
/// Recursively iterate over data in the render (including nested layer stacks upstream of a vector node, in the case of a boolean operation) to collect the footprints, click targets, and vector modify. /// Recursively iterate over data in the render (including nested layer stacks upstream of a vector node, in the case of a boolean operation) to collect the footprints, click targets, and vector modify.
fn collect_metadata(&self, _metadata: &mut RenderMetadata, _footprint: Footprint, _element_id: Option<NodeId>) {} fn collect_metadata(&self, _metadata: &mut RenderMetadata, _footprint: Footprint, _element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {}
fn contains_artboard(&self) -> bool { fn contains_artboard(&self) -> bool {
false false
@@ -665,7 +678,7 @@ impl Render for Graphic {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
if let Some(element_id) = element_id { if let Some(element_id) = element_id {
match self { match self {
Graphic::None => {} Graphic::None => {}
@@ -729,39 +742,39 @@ impl Render for Graphic {
match self { match self {
Graphic::None => (), Graphic::None => (),
Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Graphic(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Vector(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::RasterCPU(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::RasterGPU(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Color(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Gradient(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id), Graphic::Text(list) => list.collect_metadata(metadata, footprint, element_id, inherited_appearance),
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
match self { match self {
Graphic::None => (), Graphic::None => (),
Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets), Graphic::Graphic(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Vector(list) => list.add_upstream_click_targets(click_targets), Graphic::Vector(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets), Graphic::RasterCPU(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets), Graphic::RasterGPU(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Color(list) => list.add_upstream_click_targets(click_targets), Graphic::Color(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets), Graphic::Gradient(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
Graphic::Text(list) => list.add_upstream_click_targets(click_targets), Graphic::Text(list) => list.add_upstream_click_targets(click_targets, inherited_appearance),
} }
} }
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) { fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
match self { match self {
Graphic::None => (), Graphic::None => (),
Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines), Graphic::Graphic(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Vector(list) => list.add_upstream_outline_targets(outlines), Graphic::Vector(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines), Graphic::RasterCPU(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines), Graphic::RasterGPU(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Color(list) => list.add_upstream_outline_targets(outlines), Graphic::Color(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines), Graphic::Gradient(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
Graphic::Text(list) => list.add_upstream_outline_targets(outlines), Graphic::Text(list) => list.add_upstream_outline_targets(outlines, inherited_appearance),
} }
} }
@@ -890,7 +903,7 @@ impl Render for List<Artboard> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, _element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue }; let Some(content) = self.element(index).map(Artboard::as_graphic_list) else { continue };
let (location, dimensions, _background, clip) = read_artboard_attributes(self, index); let (location, dimensions, _background, clip) = read_artboard_attributes(self, index);
@@ -912,11 +925,11 @@ impl Render for List<Artboard> {
let mut child_footprint = footprint; let mut child_footprint = footprint;
child_footprint.transform *= DAffine2::from_translation(location); child_footprint.transform *= DAffine2::from_translation(location);
content.collect_metadata(metadata, child_footprint, None); content.collect_metadata(metadata, child_footprint, None, inherited_appearance);
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index); let dimensions: DVec2 = self.attribute_cloned_or_default(ATTR_DIMENSIONS, index);
let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions); let subpath_rectangle = Subpath::new_rectangle(DVec2::ZERO, dimensions);
@@ -939,6 +952,12 @@ impl Render for List<Graphic> {
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
// This item's declared appearance (if any) cascades to descendants lacking their own
let child_render_params = self
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.and_then(Appearance::declared)
.map(|appearance| render_params.for_child_item(appearance));
let render_params = child_render_params.as_ref().unwrap_or(render_params);
let matrix = format_transform_matrix(transform); let matrix = format_transform_matrix(transform);
let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled(); let next_clips = index + 1 < self.len() && self.element(index + 1).unwrap().had_clip_enabled();
@@ -1008,6 +1027,12 @@ impl Render for List<Graphic> {
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
// This item's declared appearance (if any) cascades to descendants lacking their own
let child_render_params = self
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.and_then(Appearance::declared)
.map(|appearance| render_params.for_child_item(appearance));
let render_params = child_render_params.as_ref().unwrap_or(render_params);
let mut layer = false; let mut layer = false;
@@ -1076,21 +1101,23 @@ impl Render for List<Graphic> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0; let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
let layer = layer_path.iter_element_values().next_back().copied(); let layer = layer_path.iter_element_values().next_back().copied();
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
// This item's appearance (if any) cascades to descendants lacking their own
let child_appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut footprint = footprint; let mut footprint = footprint;
footprint.transform *= item_transform; footprint.transform *= item_transform;
if let Some(element_id) = layer { if let Some(element_id) = layer {
element.collect_metadata(metadata, footprint, Some(element_id)); element.collect_metadata(metadata, footprint, Some(element_id), child_appearance);
} else { } else {
// Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags // Recurse through anonymous wrapper items to reach nested content with editor:layer_path tags
element.collect_metadata(metadata, footprint, None); element.collect_metadata(metadata, footprint, None, child_appearance);
} }
} }
@@ -1101,9 +1128,10 @@ impl Render for List<Graphic> {
for index in 0..self.len() { for index in 0..self.len() {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
let child_appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_click_targets = Vec::new(); let mut new_click_targets = Vec::new();
element.add_upstream_click_targets(&mut new_click_targets); element.add_upstream_click_targets(&mut new_click_targets, child_appearance);
for click_target in new_click_targets.iter_mut() { for click_target in new_click_targets.iter_mut() {
click_target.apply_transform(item_transform) click_target.apply_transform(item_transform)
@@ -1112,7 +1140,7 @@ impl Render for List<Graphic> {
all_upstream_click_targets.extend(new_click_targets); all_upstream_click_targets.extend(new_click_targets);
let mut new_outlines = Vec::new(); let mut new_outlines = Vec::new();
element.add_upstream_outline_targets(&mut new_outlines); element.add_upstream_outline_targets(&mut new_outlines, child_appearance);
for outline in new_outlines.iter_mut() { for outline in new_outlines.iter_mut() {
outline.apply_transform(item_transform) outline.apply_transform(item_transform)
} }
@@ -1124,13 +1152,14 @@ impl Render for List<Graphic> {
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
let child_appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_click_targets = Vec::new(); let mut new_click_targets = Vec::new();
element.add_upstream_click_targets(&mut new_click_targets); element.add_upstream_click_targets(&mut new_click_targets, child_appearance);
for click_target in new_click_targets.iter_mut() { for click_target in new_click_targets.iter_mut() {
click_target.apply_transform(item_transform) click_target.apply_transform(item_transform)
@@ -1140,13 +1169,14 @@ impl Render for List<Graphic> {
} }
} }
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) { fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let element = self.element(index).unwrap(); let element = self.element(index).unwrap();
let child_appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
let mut new_outlines = Vec::new(); let mut new_outlines = Vec::new();
element.add_upstream_outline_targets(&mut new_outlines); element.add_upstream_outline_targets(&mut new_outlines, child_appearance);
for outline in new_outlines.iter_mut() { for outline in new_outlines.iter_mut() {
outline.apply_transform(item_transform) outline.apply_transform(item_transform)
@@ -1175,16 +1205,29 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let opacity_fill_attr: f64 = list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
// The item's own declared appearance wins over one cascading down from an ancestor
let own_appearance = list.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared);
let appearance = own_appearance.or(render_params.inherited_appearance.as_ref());
let FillAndStroke {
stroke: stroke_params,
fill_paint: fill_graphic_list,
stroke_paint: stroke_graphic_list,
stroke_below: wants_stroke_below,
} = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = vector.stroke.as_ref().filter(|stroke| stroke.weight() > 0.); let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); // A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own
let set_stroke_transform = has_real_stroke
.map(|stroke| if own_appearance.is_some() { stroke.transform } else { item_transform })
.filter(|transform| transform_is_invertible(*transform));
let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform); let applied_stroke_transform = set_stroke_transform.unwrap_or(item_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform); let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse()); let element_transform = set_stroke_transform.map(|stroke_transform| item_transform * stroke_transform.inverse());
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, stroke_params.as_ref()).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]);
@@ -1196,26 +1239,22 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
path.push_str(bezpath.to_svg().as_str()); path.push_str(bezpath.to_svg().as_str());
} }
let mask_type = if vector.stroke.as_ref().map(|x| x.align) == Some(StrokeAlign::Inside) { let mask_type = if stroke_params.as_ref().map(|stroke| stroke.align) == Some(StrokeAlign::Inside) {
MaskType::Clip MaskType::Clip
} else { } else {
MaskType::Mask MaskType::Mask
}; };
let fill_graphic_list = graphic_list_at(list, index, ATTR_FILL); let fill_graphic = fill_graphic_list.and_then(|l| l.element(0));
let fill_graphic = fill_graphic_list.as_ref().and_then(|l| l.element(0)); let stroke_graphic = stroke_graphic_list.and_then(|l| l.element(0));
let stroke_graphic_list = graphic_list_at(list, index, ATTR_STROKE);
let stroke_graphic = stroke_graphic_list.as_ref().and_then(|l| l.element(0));
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed()); let path_is_closed = vector.stroke_bezier_paths().all(|path| path.closed());
let can_draw_aligned_stroke = path_is_closed let can_draw_aligned_stroke = path_is_closed
&& vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && stroke_params.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent()); && stroke_graphic.is_some_and(|graphic| !graphic.is_fully_transparent());
let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip); let can_use_paint_order = !(fill_graphic.is_none_or(|graphic| !graphic.covers_opaquely()) || mask_type == MaskType::Clip);
let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order; let needs_separate_alignment_fill = can_draw_aligned_stroke && !can_use_paint_order;
let wants_stroke_below = vector.stroke.as_ref().map(|s| s.paint_order) == Some(PaintOrder::StrokeBelow);
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order; let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill(); let use_face_fill = vector.use_face_fill();
@@ -1223,7 +1262,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path( emit_svg_fill_path(
render, render,
path.clone(), path.clone(),
fill_graphic_list.as_deref(), fill_graphic_list,
item_transform, item_transform,
element_transform, element_transform,
applied_stroke_transform, applied_stroke_transform,
@@ -1235,13 +1274,13 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
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.
let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform); let mut mask_item = Item::new_from_element(cloned_vector).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); let black_fill = List::new_from_element(Graphic::Color(List::new_from_element(Color::BLACK)));
mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill));
let vector_item = List::new_from_item(mask_item); let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item) (id, mask_type, vector_item)
@@ -1255,7 +1294,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path( emit_svg_fill_path(
render, render,
face_d, face_d,
fill_graphic_list.as_deref(), fill_graphic_list,
item_transform, item_transform,
element_transform, element_transform,
applied_stroke_transform, applied_stroke_transform,
@@ -1276,10 +1315,9 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
if let Some((ref id, mask_type, ref vector_item)) = push_id { if let Some((ref id, mask_type, ref vector_item)) = push_id {
let mut svg = SvgRender::new(); let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform)); vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.stroke.as_ref().unwrap();
// `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed` // `push_id` is only `Some` when `can_draw_aligned_stroke`, which is gated on `path_is_closed`
let (largest_scale, _) = singular_values(applied_stroke_transform); let (largest_scale, _) = singular_values(applied_stroke_transform);
let inflation = stroke.max_aabb_inflation(true) * largest_scale; let inflation = stroke_params.as_ref().map(|stroke| stroke.max_aabb_inflation(true)).unwrap_or_default() * largest_scale;
let quad = Quad::from_box(transformed_bounds).inflate(inflation); let quad = Quad::from_box(transformed_bounds).inflate(inflation);
let (x, y) = quad.top_left().into(); let (x, y) = quad.top_left().into();
let (width, height) = (quad.bottom_right() - quad.top_left()).into(); let (width, height) = (quad.bottom_right() - quad.top_left()).into();
@@ -1301,13 +1339,12 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
let mut render_params = render_params.clone(); let mut render_params = render_params.clone();
render_params.aligned_strokes = can_draw_aligned_stroke; render_params.aligned_strokes = can_draw_aligned_stroke;
render_params.override_paint_order = override_paint_order; render_params.stroke_below = override_paint_order || wants_stroke_below;
let stroke_shape_attribute = vector let stroke_shape_attribute = stroke_params
.stroke
.as_ref() .as_ref()
.map(|stroke| { .map(|stroke| {
if stroke_graphic_list.as_deref().is_some_and(is_paint_present) { if stroke_graphic_list.is_some() {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke) stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
} else { } else {
String::new() String::new()
@@ -1316,10 +1353,10 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
.unwrap_or_default(); .unwrap_or_default();
// Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback // Need to avoid generating only paint attribute, otherwise SVG uses 1px width stroke as a fallback
let stroke_visible = vector.stroke.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent()); let stroke_visible = stroke_params.as_ref().is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible { let stroke_attribute = if stroke_visible {
stroke_graphic_list stroke_graphic_list
.as_deref() .as_ref()
.map(|list| { .map(|list| {
// Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke. // Gradient should align with the fill path bbox so that a shared gradient lines up across fill and stroke.
// Only clipping-based paints need the stroke-inclusive bbox. // Only clipping-based paints need the stroke-inclusive bbox.
@@ -1338,7 +1375,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
r#" fill="none""#.to_string() r#" fill="none""#.to_string()
} else { } else {
fill_graphic_list fill_graphic_list
.as_deref() .as_ref()
.map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill)) .map(|list| list.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Fill))
.unwrap_or_else(|| r#" fill="none""#.to_string()) .unwrap_or_else(|| r#" fill="none""#.to_string())
}; };
@@ -1370,7 +1407,7 @@ fn render_vector_item_svg(list: &List<Vector>, index: usize, vector: &Vector, re
emit_svg_fill_path( emit_svg_fill_path(
render, render,
path.clone(), path.clone(),
fill_graphic_list.as_deref(), fill_graphic_list,
item_transform, item_transform,
element_transform, element_transform,
applied_stroke_transform, applied_stroke_transform,
@@ -1425,17 +1462,35 @@ impl Render for List<Vector> {
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let mut clip_masker: Option<List<Vector>> = None; let mut clip_masker: Option<List<Vector>> = None;
for index in 0..self.len() { // A paint subtree supplies its own styling, so an element's appearance must not cascade into it
use graphic_types::vector_types::vector; let paint_render_params = RenderParams {
inherited_appearance: None,
..render_params.clone()
};
for index in 0..self.len() {
let Some(element) = self.element(index) else { continue }; let Some(element) = self.element(index) else { continue };
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let blend_mode_attr: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index);
let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let multiplied_transform = parent_transform * item_transform; let multiplied_transform = parent_transform * item_transform;
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*transform)); // The item's own declared appearance wins over one cascading down from an ancestor
let own_appearance = self.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared);
let appearance = own_appearance.or(render_params.inherited_appearance.as_ref());
let FillAndStroke {
stroke: stroke_params,
fill_paint: fill_graphic_list,
stroke_paint: stroke_graphic_list,
stroke_below: wants_stroke_below,
} = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let has_real_stroke = stroke_params.as_ref().filter(|stroke| stroke.weight() > 0.);
// A cascaded coverage records its stroke space in the ancestor's coordinates, so this item authors its own
let set_stroke_transform = has_real_stroke
.map(|stroke| if own_appearance.is_some() { stroke.transform } else { item_transform })
.filter(|transform| transform_is_invertible(*transform));
let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let mut element_transform = set_stroke_transform let mut element_transform = set_stroke_transform
.map(|stroke_transform| multiplied_transform * stroke_transform.inverse()) .map(|stroke_transform| multiplied_transform * stroke_transform.inverse())
@@ -1458,9 +1513,6 @@ impl Render for List<Vector> {
} }
} }
let fill_graphic_list = graphic_list_at(self, index, ATTR_FILL);
let stroke_graphic_list = graphic_list_at(self, index, ATTR_STROKE);
// If we're using opacity or a blend mode, we need to push a layer // If we're using opacity or a blend mode, we need to push a layer
let blend_mode = match render_params.render_mode { let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal, RenderMode::Outline => peniko::Mix::Normal,
@@ -1471,8 +1523,8 @@ impl Render for List<Vector> {
// Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path). // Whether the renderer will engage the stroke-alignment compositing trick (non-Center align on a fully closed path).
// Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since // Used by both the blend-layer clip rect inflation below (as `max_aabb_inflation`'s `path_is_closed` arg, equivalent here since
// the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down. // the function ignores the arg for Center align) and the `SrcIn`/`SrcOut` aligned-stroke branch further down.
let stroke = element.stroke.as_ref(); let stroke = stroke_params.as_ref();
let stroke_fully_transparent = stroke_graphic_list.as_ref().is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent())); let stroke_fully_transparent = stroke_graphic_list.is_none_or(|l| l.element(0).is_none_or(|g| g.is_fully_transparent()));
let can_draw_aligned_stroke = let can_draw_aligned_stroke =
!stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed()); !stroke_fully_transparent && stroke.is_some_and(|s| s.has_renderable_stroke() && s.align.is_not_centered()) && element.stroke_bezier_paths().all(|p| p.closed());
@@ -1526,10 +1578,9 @@ impl Render for List<Vector> {
} }
let use_layer = can_draw_aligned_stroke; let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| { let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list.as_deref() else { return }; let Some(fill_graphic) = fill_graphic_list else { return };
for paint_index in 0..fill_graphic.len() { for paint_index in 0..fill_graphic.len() {
let Some(paint) = fill_graphic.element(paint_index) else { continue }; let Some(paint) = fill_graphic.element(paint_index) else { continue };
@@ -1556,7 +1607,7 @@ impl Render for List<Vector> {
} }
Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => { Graphic::Vector(_) | Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Graphic(_) | Graphic::Text(_) => {
scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path); scene.push_clip_layer(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), path);
paint.render_to_vello(scene, multiplied_transform, context, render_params); paint.render_to_vello(scene, multiplied_transform, context, &paint_render_params);
scene.pop_layer(); scene.pop_layer();
} }
}; };
@@ -1583,7 +1634,7 @@ impl Render for List<Vector> {
}; };
let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| { let do_stroke = |scene: &mut Scene, width_scale: f64, context: &mut RenderContext| {
let Some(stroke_graphic_list) = stroke_graphic_list.as_deref() else { return }; let Some(stroke_graphic_list) = stroke_graphic_list else { return };
let Some(stroke) = stroke else { return }; let Some(stroke) = stroke else { return };
for paint_index in 0..stroke_graphic_list.len() { for paint_index in 0..stroke_graphic_list.len() {
@@ -1641,7 +1692,7 @@ impl Render for List<Vector> {
let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01); let stroked = peniko::kurbo::stroke(path.iter(), &stroke, &StrokeOpts::default(), 0.01);
scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked); scene.push_clip_layer(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &stroked);
stroke_graphic.render_to_vello(scene, multiplied_transform, context, render_params); stroke_graphic.render_to_vello(scene, multiplied_transform, context, &paint_render_params);
scene.pop_layer(); scene.pop_layer();
} }
}; };
@@ -1657,13 +1708,13 @@ impl Render for List<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.
let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform); let mut mask_item = Item::new_from_element(cloned_element).with_attribute(ATTR_TRANSFORM, item_transform);
set_paint_attribute(mask_item.attributes_mut(), ATTR_FILL, List::new_from_element(Color::BLACK)); let black_fill = List::new_from_element(Graphic::Color(List::new_from_element(Color::BLACK)));
mask_item.set_attribute(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), black_fill));
let vector_list = List::new_from_item(mask_item); let vector_list = List::new_from_item(mask_item);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds); let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
@@ -1711,7 +1762,7 @@ impl Render for List<Vector> {
Stroke, Stroke,
} }
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) { let order = match wants_stroke_below {
true => [Op::Stroke, Op::Fill], true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default false => [Op::Fill, Op::Stroke], // Default
}; };
@@ -1738,7 +1789,7 @@ impl Render for List<Vector> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>, inherited_appearance: Option<&Appearance>) {
// Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph. // Aggregate all items' targets per element_id so multi-item lists (e.g. the "Text to Vector Glyphs" node) produce hit areas for every glyph.
// Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry. // Targets are baked relative to the first item carrying each element_id, since that is the transform recorded as its `local_transforms` entry.
let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new(); let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new();
@@ -1751,6 +1802,8 @@ impl Render for List<Vector> {
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0; let layer_path: List<NodeId> = self.attribute_cloned_or_default::<NodeIdPath>(ATTR_EDITOR_LAYER_PATH, index).0;
let layer = layer_path.iter_element_values().next_back().copied(); let layer = layer_path.iter_element_values().next_back().copied();
// The item's own appearance wins over one cascading down from an ancestor
let appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
if let Some(element_id) = caller_element_id.or(layer) { if let Some(element_id) = caller_element_id.or(layer) {
let reference_transform = *reference_transforms.entry(element_id).or_insert(transform); let reference_transform = *reference_transforms.entry(element_id).or_insert(transform);
@@ -1766,12 +1819,12 @@ impl Render for List<Vector> {
let item_relative_transform = reference_inverse * transform; let item_relative_transform = reference_inverse * transform;
let mut click_targets_unwrapped = Vec::new(); let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, self, index, click_target_vector, item_relative_transform); extend_targets_from_vector(&mut click_targets_unwrapped, appearance, click_target_vector, item_relative_transform);
accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new)); accumulated_click_targets.entry(element_id).or_default().extend(click_targets_unwrapped.into_iter().map(Arc::new));
// Outlines always use source geometry so the visual outline reflects actual letterforms // Outlines always use source geometry so the visual outline reflects actual letterforms
let mut outlines_unwrapped = Vec::new(); let mut outlines_unwrapped = Vec::new();
extend_targets_from_vector(&mut outlines_unwrapped, self, index, source, item_relative_transform); extend_targets_from_vector(&mut outlines_unwrapped, appearance, source, item_relative_transform);
accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new)); accumulated_outlines.entry(element_id).or_default().extend(outlines_unwrapped.into_iter().map(Arc::new));
// Source geometry (not the click-target override) so editing tools work on letterforms. // Source geometry (not the click-target override) so editing tools work on letterforms.
@@ -1781,11 +1834,8 @@ impl Render for List<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(source.clone())); e.insert(Arc::new(source.clone()));
if let Some(fill_graphic) = graphic_list_at(self, index, ATTR_FILL) { if let Some(appearance) = appearance {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.into_owned())); metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone()));
}
if let Some(stroke_graphic) = graphic_list_at(self, index, ATTR_STROKE) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.into_owned()));
} }
} }
@@ -1802,7 +1852,8 @@ impl Render for List<Vector> {
if !upstream_nested_layers.is_empty() { if !upstream_nested_layers.is_empty() {
let mut upstream_footprint = footprint; let mut upstream_footprint = footprint;
upstream_footprint.transform *= transform; upstream_footprint.transform *= transform;
upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None); // Snapshot layers carry their own styling, so the merged result's appearance must not cascade into them
upstream_nested_layers.collect_metadata(metadata, upstream_footprint, None, None);
} }
} }
@@ -1824,25 +1875,27 @@ impl Render for List<Vector> {
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let Some(source) = self.element(index) else { continue }; let Some(source) = self.element(index) else { continue };
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes) // Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
let vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source); let vector = self.attribute::<Vector>(ATTR_EDITOR_CLICK_TARGET, index).unwrap_or(source);
extend_targets_from_vector(click_targets, self, index, vector, transform); extend_targets_from_vector(click_targets, appearance, vector, transform);
} }
} }
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) { fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, inherited_appearance: Option<&Appearance>) {
// Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms // Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms
for index in 0..self.len() { for index in 0..self.len() {
let Some(source) = self.element(index) else { continue }; let Some(source) = self.element(index) else { continue };
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let appearance = Appearance::cascade(self.attribute::<Appearance>(ATTR_APPEARANCE, index), inherited_appearance);
extend_targets_from_vector(outlines, self, index, source, transform); extend_targets_from_vector(outlines, appearance, source, transform);
} }
} }
@@ -1855,15 +1908,17 @@ impl Render for List<Vector> {
/// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work /// Build one `CompoundPath` (non-zero fill rule, so holes like the inside of an "O" work
/// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append. /// correctly) plus one `FreePoint` per disconnected anchor, apply the transform, and append.
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, vector_list: &List<Vector>, index: usize, geometry: &Vector, transform: DAffine2) { fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, appearance: Option<&Appearance>, geometry: &Vector, transform: DAffine2) {
let filled = has_paint_at(vector_list, index, ATTR_FILL); // A coverage whose paint is `Graphic::None` exists but paints nothing, so it does not close subpaths for hit testing
let filled = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill));
let mut subpaths: Vec<Subpath<_>> = geometry.stroke_bezier_paths().collect(); let mut subpaths: Vec<Subpath<_>> = geometry.stroke_bezier_paths().collect();
let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed()); let all_subpaths_closed = subpaths.iter().all(|subpath| subpath.closed());
// Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side, // Inside/Outside-aligned strokes reach `weight` from the centerline rather than `weight / 2` per side,
// so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths. // so they need double the click inflation. Alignment is only honored by the renderer for fully-closed paths.
let stroke_width = geometry.stroke.as_ref().map_or(0., |stroke| { let stroke_width = appearance.and_then(|appearance| appearance.first_coverage_of(Cover::Stroke)).map_or(0., |coverage| {
let stroke = coverage.stroke_params();
if stroke.align.is_not_centered() && all_subpaths_closed { if stroke.align.is_not_centered() && all_subpaths_closed {
stroke.weight * 2. stroke.weight * 2.
} else { } else {
@@ -2048,7 +2103,7 @@ impl Render for List<Raster<CPU>> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return }; let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
@@ -2067,12 +2122,12 @@ impl Render for List<Raster<CPU>> {
// multiply in `transform` (which is the rasterization area, not a layer-stack transform). // multiply in `transform` (which is the rasterization area, not a layer-stack transform).
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0); let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
if !upstream_nested_layers.is_empty() { if !upstream_nested_layers.is_empty() {
upstream_nested_layers.collect_metadata(metadata, footprint, None); upstream_nested_layers.collect_metadata(metadata, footprint, None, None);
} }
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
// The unit square is the raster's own space, so its placement only exists in the item transform // The unit square is the raster's own space, so its placement only exists in the item transform
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
@@ -2149,7 +2204,7 @@ impl Render for List<Raster<GPU>> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return }; let Some(element_id) = element_id else { return };
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
@@ -2168,12 +2223,12 @@ impl Render for List<Raster<GPU>> {
// multiply in `transform` (which is the rasterization area, not a layer-stack transform). // multiply in `transform` (which is the rasterization area, not a layer-stack transform).
let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0); let upstream_nested_layers = self.attribute_cloned_or_default::<List<Graphic>>(ATTR_EDITOR_MERGED_LAYERS, 0);
if !upstream_nested_layers.is_empty() { if !upstream_nested_layers.is_empty() {
upstream_nested_layers.collect_metadata(metadata, footprint, None); upstream_nested_layers.collect_metadata(metadata, footprint, None, None);
} }
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE); let mut subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::ONE);
@@ -2432,7 +2487,7 @@ impl Render for List<Gradient> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, _footprint: Footprint, element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
let Some(element_id) = element_id else { return }; let Some(element_id) = element_id else { return };
if self.is_empty() { if self.is_empty() {
return; return;
@@ -2468,7 +2523,7 @@ impl Render for List<Gradient> {
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index);
if !gradient_control_interior_is_clickable(gradient_form) { if !gradient_control_interior_is_clickable(gradient_form) {
@@ -2482,7 +2537,7 @@ impl Render for List<Gradient> {
} }
} }
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) { fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index); let gradient_form: GradientForm = self.attribute_cloned_or_default(ATTR_GRADIENT_FORM, index);
let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index);
@@ -2813,7 +2868,7 @@ impl Render for List<String> {
} }
} }
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) { fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>, _inherited_appearance: Option<&Appearance>) {
// Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`. // Click targets are baked relative to item 0's transform, which `Graphic::collect_metadata` records as `local_transforms[element_id]`.
let item_zero_transform: DAffine2 = if !self.is_empty() { let item_zero_transform: DAffine2 = if !self.is_empty() {
self.attribute_cloned_or_default(ATTR_TRANSFORM, 0) self.attribute_cloned_or_default(ATTR_TRANSFORM, 0)
@@ -2853,7 +2908,7 @@ impl Render for List<String> {
} }
} }
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) { fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>, _inherited_appearance: Option<&Appearance>) {
for index in 0..self.len() { for index in 0..self.len() {
let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue }; let Some((size, transform)) = text_item_size_and_transform(self, index) else { continue };
let subpath = Subpath::new_rectangle(DVec2::ZERO, size); let subpath = Subpath::new_rectangle(DVec2::ZERO, size);
@@ -715,7 +715,7 @@ impl SmoothPath {
} }
} }
/// Stepped holds each stop's color the whole way to the next stop, so the ramp jumps at stops and midpoints are inert. /// Stepped holds each stop's color the whole way to the next stop, so the ramp jumps at stops and midpoints are ignored.
fn stepped_color(stops: &[GradientStop], t: f64, gradient_cyclic: bool) -> Color { fn stepped_color(stops: &[GradientStop], t: f64, gradient_cyclic: bool) -> Color {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK }; let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return Color::BLACK };
@@ -563,9 +563,9 @@ pub struct HandleId {
impl std::fmt::Display for HandleId { impl std::fmt::Display for HandleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.ty { match self.ty {
// I haven't checked if "out" and "in" are reversed, or are accurate translations of the "primary" and "end" terms used in the `HandleType` enum, so this naming is an assumption. // The primary handle sits at the segment's start anchor and the end handle at its end anchor
HandleType::Primary => write!(f, "{} out", self.segment.inner()), HandleType::Primary => write!(f, "Segment {} (start handle)", self.segment.inner()),
HandleType::End => write!(f, "{} in", self.segment.inner()), HandleType::End => write!(f, "Segment {} (end handle)", self.segment.inner()),
} }
} }
} }
@@ -153,6 +153,8 @@ impl StrokeAlign {
} }
} }
// Backs the control bar's stroke popover radio and legacy document parsing: the relative order
// of the Fill and Stroke nodes in the chain is what actually determines the paint order
#[repr(C)] #[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
@@ -235,8 +237,6 @@ pub struct Stroke {
pub align: StrokeAlign, pub align: StrokeAlign,
#[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))] #[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))]
pub transform: DAffine2, pub transform: DAffine2,
#[cfg_attr(feature = "serde", serde(default))]
pub paint_order: PaintOrder,
} }
impl Stroke { impl Stroke {
@@ -250,7 +250,6 @@ impl Stroke {
join_miter_limit: 4., join_miter_limit: 4.,
align: StrokeAlign::Center, align: StrokeAlign::Center,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
} }
} }
@@ -287,7 +286,6 @@ impl Stroke {
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]); let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
trs * skew trs * skew
}, },
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
} }
} }
@@ -402,7 +400,6 @@ impl Default for Stroke {
join_miter_limit: 4., join_miter_limit: 4.,
align: StrokeAlign::Center, align: StrokeAlign::Center,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
paint_order: PaintOrder::default(),
} }
} }
} }
@@ -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);
} }
} }
@@ -247,10 +242,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());
@@ -538,40 +533,16 @@ 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;
}
}
} }
// The element sees only geometry; stroke inflation is applied at the row level by `vector_list_bounding_box`
// in graphic-types, which can read the appearance attribute the stroke parameters live on.
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, CoverPlacement, Coverage, Graphic, Vector, stamp_coverage};
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;
+4 -4
View File
@@ -44,7 +44,7 @@ pub struct CacheKey {
pub for_mask: bool, pub for_mask: bool,
pub thumbnail: bool, pub thumbnail: bool,
pub aligned_strokes: bool, pub aligned_strokes: bool,
pub override_paint_order: bool, pub stroke_below: bool,
pub animation_time_ms: i64, pub animation_time_ms: i64,
pub real_time_ms: i64, pub real_time_ms: i64,
pub pointer: [u8; 16], pub pointer: [u8; 16],
@@ -61,7 +61,7 @@ impl CacheKey {
for_mask: bool, for_mask: bool,
thumbnail: bool, thumbnail: bool,
aligned_strokes: bool, aligned_strokes: bool,
override_paint_order: bool, stroke_below: bool,
animation_time: f64, animation_time: f64,
real_time: f64, real_time: f64,
pointer: Option<DVec2>, pointer: Option<DVec2>,
@@ -86,7 +86,7 @@ impl CacheKey {
for_mask, for_mask,
thumbnail, thumbnail,
aligned_strokes, aligned_strokes,
override_paint_order, stroke_below,
animation_time_ms: (animation_time * 1000.).round() as i64, animation_time_ms: (animation_time * 1000.).round() as i64,
real_time_ms: (real_time * 1000.).round() as i64, real_time_ms: (real_time * 1000.).round() as i64,
pointer: pointer_bytes, pointer: pointer_bytes,
@@ -363,7 +363,7 @@ pub async fn render_output_cache<'a: 'n>(
render_params.for_mask, render_params.for_mask,
render_params.thumbnail, render_params.thumbnail,
render_params.aligned_strokes, render_params.aligned_strokes,
render_params.override_paint_order, render_params.stroke_below,
ctx.try_animation_time().unwrap_or(0.), ctx.try_animation_time().unwrap_or(0.),
ctx.try_real_time().unwrap_or(0.), ctx.try_real_time().unwrap_or(0.),
ctx.try_pointer_position(), ctx.try_pointer_position(),
+1 -1
View File
@@ -47,7 +47,7 @@ async fn render_intermediate<'a: 'n, T: 'static + Render + WasmNotSend + Send +
let footprint = Footprint::default(); let footprint = Footprint::default();
let mut metadata = RenderMetadata::default(); let mut metadata = RenderMetadata::default();
data.collect_metadata(&mut metadata, footprint, None); data.collect_metadata(&mut metadata, footprint, None, None);
let intermediate = match &render_params.render_output_type { let intermediate = match &render_params.render_output_type {
RenderOutputTypeRequest::Vello => { RenderOutputTypeRequest::Vello => {
let mut scene = vello::Scene::new(); let mut scene = vello::Scene::new();
+26 -8
View File
@@ -1,6 +1,7 @@
use core_types::list::{Item, List, NodeIdPath}; use core_types::list::{ATTR_APPEARANCE, Item, List, NodeIdPath};
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Ctx}; use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Ctx};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use graphic_types::Appearance;
use graphic_types::graphic::bake_paint_transforms; use graphic_types::graphic::bake_paint_transforms;
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath}; use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
use graphic_types::vector_types::vector::PointId; use graphic_types::vector_types::vector::PointId;
@@ -45,7 +46,15 @@ async fn boolean_operation<I: graphic_types::IntoGraphicList>(
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);
// The geometry is baked into identity space, so a copied stroke authoring space would be stale
if let Some(appearance) = result_vector_list.attribute::<Appearance>(ATTR_APPEARANCE, 0) {
let mut appearance = appearance.clone();
for coverage in appearance.0.iter_element_values_mut() {
coverage.0.remove_attribute::<DAffine2>(ATTR_TRANSFORM);
}
result_vector_list.set_attribute(ATTR_APPEARANCE, 0, appearance);
}
// Snapshot the input layers as the `editor:merged_layers` attribute so the renderer can recurse into them // Snapshot the input layers as the `editor:merged_layers` attribute so the renderer can recurse into them
// for editor click-target preservation. // for editor click-target preservation.
@@ -142,12 +151,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()
}; };
@@ -191,6 +195,7 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
let parent_opacity: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY, index, 1.); let parent_opacity: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY, index, 1.);
let parent_fill: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let parent_fill: f64 = graphic_list.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.);
let layer_path: NodeIdPath = graphic_list.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index); let layer_path: NodeIdPath = graphic_list.attribute_cloned_or_default(ATTR_EDITOR_LAYER_PATH, index);
let parent_appearance = graphic_list.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared).cloned();
let compose_parent = |mut item: Item<Vector>| { let compose_parent = |mut item: Item<Vector>| {
if parent_has_transform || item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some() { if parent_has_transform || item.attribute::<DAffine2>(ATTR_TRANSFORM).is_some() {
@@ -208,6 +213,12 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
if parent_has_layer_path { if parent_has_layer_path {
item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone()); item.set_attribute(ATTR_EDITOR_LAYER_PATH, layer_path.clone());
} }
// Appearance cascades into each child whose own is undeclared, since a declared child wins wholesale
if let Some(appearance) = &parent_appearance
&& item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).is_none()
{
item.set_attribute(ATTR_APPEARANCE, appearance.clone());
}
item item
}; };
@@ -231,6 +242,13 @@ fn flatten_vector(graphic_list: &List<Graphic>) -> List<Vector> {
*fill *= parent_fill; *fill *= parent_fill;
} }
} }
if let Some(appearance) = &parent_appearance {
for value in graphic.iter_attribute_values_mut_or_default::<Appearance>(ATTR_APPEARANCE) {
if value.is_empty() {
*value = appearance.clone();
}
}
}
// Unioning each group lets it enter the outer operation as one region, which is why this cannot defer // Unioning each group lets it enter the outer operation as one region, which is why this cannot defer
// to `flatten_graphic_list`. Splicing children in as sibling operands makes them subtract from each other. // to `flatten_graphic_list`. Splicing children in as sibling operands makes them subtract from each other.
+182 -140
View File
@@ -3,7 +3,7 @@ use core::f64::consts::{PI, TAU};
use core::hash::{Hash, Hasher}; use core::hash::{Hash, Hasher};
use core_types::blending::BlendMode; use core_types::blending::BlendMode;
use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::list::{ATTR_FILL, ATTR_STROKE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath}; use core_types::list::{ATTR_APPEARANCE, Item, ItemAttributeValues, List, ListDyn, NodeIdPath};
use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue};
use core_types::transform::{Footprint, Transform}; use core_types::transform::{Footprint, Transform};
use core_types::uuid::NodeId; use core_types::uuid::NodeId;
@@ -13,9 +13,9 @@ use core_types::{
}; };
use glam::{DAffine2, DMat2, DVec2}; use glam::{DAffine2, DMat2, DVec2};
use graphic_types::Vector; use graphic_types::Vector;
use graphic_types::graphic::{bake_paint_transforms, graphic_list_at, has_paint_at, is_paint_present, set_paint_attribute_at}; use graphic_types::graphic::{bake_paint_transforms, is_paint_present};
use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{Graphic, IntoGraphicList}; use graphic_types::{Appearance, Cover, CoverPlacement, Coverage, Graphic, IntoGraphicList, stamp_coverage};
use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
use rand::{Rng, SeedableRng}; use rand::{Rng, SeedableRng};
@@ -32,7 +32,7 @@ use vector_types::vector::misc::{
CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups,
bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
}; };
use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::style::{DashPattern, Gradient, GradientSettings, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::vector::{PointDomain, RegionDomain}; use vector_types::vector::{PointDomain, RegionDomain};
@@ -72,8 +72,6 @@ impl VectorListIterMut for List<Vector> {
/// single `Item<Vector>` or `Item<Graphic>`. /// single `Item<Vector>` or `Item<Graphic>`.
trait VectorItemMut { trait VectorItemMut {
fn for_each_vector_mut(&mut self, f: impl FnMut(&mut Vector, DAffine2)); fn for_each_vector_mut(&mut self, f: impl FnMut(&mut Vector, DAffine2));
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>);
} }
impl VectorItemMut for Item<Vector> { impl VectorItemMut for Item<Vector> {
@@ -81,10 +79,6 @@ impl VectorItemMut for Item<Vector> {
let transform = self.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM); let transform = self.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
f(self.element_mut(), transform); f(self.element_mut(), transform);
} }
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
self.set_attribute(key, paint);
}
} }
impl VectorItemMut for Item<Graphic> { impl VectorItemMut for Item<Graphic> {
@@ -95,13 +89,6 @@ impl VectorItemMut for Item<Graphic> {
f(vector, *transform); f(vector, *transform);
} }
} }
fn set_vector_paint(&mut self, key: &str, paint: List<Graphic>) {
let Some(vector_list) = self.element_mut().as_vector_mut() else { return };
for slot in vector_list.iter_attribute_values_mut_or_default::<List<Graphic>>(key) {
*slot = paint.clone();
}
}
} }
/// Geometry counterpart to [`VectorItemMut`] for the element-wise modifier nodes, running a per-item transformation over /// Geometry counterpart to [`VectorItemMut`] for the element-wise modifier nodes, running a per-item transformation over
@@ -252,13 +239,24 @@ where
}; };
let color = evaluator.evaluate(factor); let color = evaluator.evaluate(factor);
let paint = List::new_from_element(color).into_graphic_list(); let color_paint = List::new_from_element(color).into_graphic_list();
if fill { if fill {
set_paint_attribute_at(vector_list, index, ATTR_FILL, paint.clone()); vector_list.with_attribute_mut_or_default::<Appearance, _, _>(ATTR_APPEARANCE, index, |appearance| {
if !appearance.set_paint_of(Cover::Fill, color_paint.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), color_paint.clone(), CoverPlacement::Below);
}
});
} }
if stroke && vector_list.element(index).is_some_and(|vector| vector.stroke.is_some()) { // The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
set_paint_attribute_at(vector_list, index, ATTR_STROKE, paint.clone()); if stroke
&& vector_list
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_cover(Cover::Stroke))
{
vector_list.with_attribute_mut_or_default::<Appearance, _, _>(ATTR_APPEARANCE, index, |appearance| {
appearance.set_paint_of(Cover::Stroke, color_paint.clone());
});
} }
i += 1; i += 1;
@@ -339,7 +337,8 @@ where
} }
} }
content.set_vector_paint(ATTR_FILL, fill); // Appending follows the painter's algorithm: the most downstream paint node in the chain paints on top
stamp_coverage(&mut content, Coverage::new_fill(), fill, CoverPlacement::Above);
content content
} }
@@ -370,9 +369,6 @@ async fn stroke<V, P: IntoGraphicList + 'n + Send + 'static>(
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio. /// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
#[default(4.)] #[default(4.)]
miter_limit: Item<f64>, miter_limit: Item<f64>,
// <https://svgwg.org/svg2-draft/painting.html#PaintOrderProperty>
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
paint_order: Item<PaintOrder>,
/// The stroke dash pattern. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed. /// The stroke dash pattern. Each length forms a distance in a pattern where the first length is a dash, the second is a gap, and so on. If the list is an odd length, the pattern repeats with solid-gap roles reversed.
dash_pattern: Item<DashPattern>, dash_pattern: Item<DashPattern>,
/// The phase offset distance from the starting point of the dash pattern. /// The phase offset distance from the starting point of the dash pattern.
@@ -383,13 +379,12 @@ where
Item<V>: VectorItemMut + 'n + Send, Item<V>: VectorItemMut + 'n + Send,
{ {
let mut content = content; let mut content = content;
let (weight, align, cap, join, miter_limit, paint_order, dash_offset) = ( let (weight, align, cap, join, miter_limit, dash_offset) = (
weight.into_element(), weight.into_element(),
align.into_element(), align.into_element(),
cap.into_element(), cap.into_element(),
join.into_element(), join.into_element(),
miter_limit.into_element(), miter_limit.into_element(),
paint_order.into_element(),
dash_offset.into_element(), dash_offset.into_element(),
); );
let dash_lengths = dash_pattern.into_element().clamped_lengths(); let dash_lengths = dash_pattern.into_element().clamped_lengths();
@@ -403,17 +398,17 @@ where
join_miter_limit: miter_limit, join_miter_limit: miter_limit,
align, align,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
paint_order,
}; };
content.for_each_vector_mut(|vector, transform| {
let mut stroke = stroke.clone();
stroke.transform *= transform;
vector.stroke = Some(stroke);
});
let paint = paint.into_graphic_list(); let paint = paint.into_graphic_list();
content.set_vector_paint(ATTR_STROKE, paint);
// The coverage records the stroke's authoring space, so the item transform is composed in. Its translation
// cancels out in every consumer, so it is cleared to let an otherwise-identity capture elide.
let mut coverage_stroke = stroke;
coverage_stroke.transform *= content.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM);
coverage_stroke.transform.translation = DVec2::ZERO;
stamp_coverage(&mut content, Coverage::new_stroke(&coverage_stroke), paint, CoverPlacement::Above);
content content
} }
@@ -543,10 +538,7 @@ async fn round_corners<V: MapVectorItems + 'n + Send>(
// 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());
@@ -923,8 +915,6 @@ async fn box_warp<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementations(
}); });
} }
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
*row.element_mut() = result; *row.element_mut() = result;
row.set_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY); row.set_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY);
@@ -1086,10 +1076,7 @@ async fn auto_tangents<V: MapVectorItems + 'n + Send>(
let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM); let transform: DAffine2 = source.attribute_cloned_or_default(ATTR_TRANSFORM);
let (source, attributes) = source.into_parts(); let (source, attributes) = source.into_parts();
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);
@@ -1225,7 +1212,7 @@ async fn auto_tangents<V: MapVectorItems + 'n + Send>(
async fn bounding_box<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item<V>) -> Item<V> { async fn bounding_box<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementations(Graphic, Vector)] content: Item<V>) -> Item<V> {
V::map_vector_items(content, |content| { V::map_vector_items(content, |content| {
let mut content = content; let mut content = content;
let mut result = content let result = content
.element() .element()
.bounding_box_rect() .bounding_box_rect()
.map(|bbox| { .map(|bbox| {
@@ -1235,9 +1222,6 @@ async fn bounding_box<V: MapVectorItems + 'n + Send>(_: impl Ctx, #[implementati
}) })
.unwrap_or_default(); .unwrap_or_default();
result.stroke = std::mem::take(&mut content.element_mut().stroke);
result.set_stroke_transform(DAffine2::IDENTITY);
*content.element_mut() = result; *content.element_mut() = result;
content content
}) })
@@ -1481,11 +1465,7 @@ async fn offset_path<V: MapVectorItems + 'n + Send>(
let vector = std::mem::take(content.element_mut()); let vector = std::mem::take(content.element_mut());
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 {
@@ -1568,16 +1548,28 @@ fn solidify_stroke_list_with_snapshot(source: List<Vector>) -> List<Vector> {
/// Replaces each item's stroke with filled outline geometry, emitting a separate fill item beside it when the /// Replaces each item's stroke with filled outline geometry, emitting a separate fill item beside it when the
/// original carried a fill. Grows the item count by up to 2x. /// original carried a fill. Grows the item count by up to 2x.
fn solidify_stroke_list(content: List<Vector>) -> List<Vector> { fn solidify_stroke_list(content: List<Vector>) -> List<Vector> {
// A fill exists when the canonical attribute carries paint // A fill exists when its coverage carries paint that draws something
let has_fills: Vec<bool> = (0..content.len()).map(|index| has_paint_at(&content, index, ATTR_FILL)).collect(); let has_fills: Vec<bool> = (0..content.len())
.map(|index| {
content
.attribute::<Appearance>(ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill))
})
.collect();
content content
.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>(ATTR_APPEARANCE).cloned().unwrap_or_default();
let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default();
// List order is paint order: a stroke coverage before the first fill coverage paints below it
let stroke_below = appearance
.first_index_of(Cover::Stroke)
.zip(appearance.first_index_of(Cover::Fill))
.is_some_and(|(stroke_index, fill_index)| stroke_index < fill_index);
let bezpaths = vector.stroke_bezpath_iter(); let bezpaths = vector.stroke_bezpath_iter();
let mut solidified_stroke = Vector::default(); let mut solidified_stroke = Vector::default();
@@ -1593,9 +1585,8 @@ fn solidify_stroke_list(content: List<Vector>) -> List<Vector> {
StrokeCap::Square => kurbo::Cap::Square, StrokeCap::Square => kurbo::Cap::Square,
}; };
let dash_offset = stroke.dash_offset; let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths; let dash_pattern = stroke.dash_lengths.clone();
let miter_limit = stroke.join_miter_limit; let miter_limit = stroke.join_miter_limit;
let paint_order = stroke.paint_order;
let stroke_style = kurbo::Stroke::new(stroke.weight) let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap) .with_caps(cap)
@@ -1624,26 +1615,29 @@ fn solidify_stroke_list(content: 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(mut appearance) = fill_attributes.remove::<Appearance>(ATTR_APPEARANCE) {
fill_attributes.remove::<List<Graphic>>(ATTR_STROKE); appearance.retain_cover(Cover::Fill);
fill_attributes.insert(ATTR_APPEARANCE, appearance);
}
Item::from_parts(vector, fill_attributes) Item::from_parts(vector, fill_attributes)
}); });
// The outlined stroke geometry becomes a fill painted with the original stroke's paint
let mut stroke_attributes = attributes; let mut stroke_attributes = attributes;
// Drop the original fill and use the stroke paint to fill the outlined stroke if stroke_attributes.remove::<Appearance>(ATTR_APPEARANCE).is_some() {
stroke_attributes.remove::<List<Graphic>>(ATTR_FILL); let stroke_paint = appearance.first_index_of(Cover::Stroke).and_then(|index| appearance.paint_at(index).cloned()).unwrap_or_default();
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL); stroke_attributes.insert(ATTR_APPEARANCE, Appearance::new_single(Coverage::new_fill(), stroke_paint));
}
let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes); let stroke_row = Item::from_parts(solidified_stroke, stroke_attributes);
// Ordering based on the paint order. The first item in the `List` is rendered below the second. // The first item in the `List` is rendered below the second
match paint_order { match stroke_below {
PaintOrder::StrokeAbove => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(), false => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
PaintOrder::StrokeBelow => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(), true => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
} }
}) })
.collect() .collect()
@@ -1667,7 +1661,6 @@ async fn separate_subpaths<V: ExpandVectorItems + 'n + Send>(_: impl Ctx, #[impl
return List::new_from_item(content); return List::new_from_item(content);
} }
let stroke = content.element().stroke.clone();
let (_, attributes) = content.into_parts(); let (_, attributes) = content.into_parts();
bezpaths bezpaths
@@ -1675,7 +1668,6 @@ async fn separate_subpaths<V: ExpandVectorItems + 'n + Send>(_: impl Ctx, #[impl
.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())
}) })
@@ -1743,10 +1735,6 @@ pub async fn combine_paths<T: IntoGraphicList>(_: impl Ctx, #[implementations(Li
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));
} }
@@ -1754,8 +1742,7 @@ pub async fn combine_paths<T: IntoGraphicList>(_: impl Ctx, #[implementations(Li
let source_attributes = flattened.clone_item_attributes(primary); let source_attributes = flattened.clone_item_attributes(primary);
let mut attributes = ItemAttributeValues::new(); let mut attributes = ItemAttributeValues::new();
attributes.insert_cloned_from(&source_attributes, ATTR_FILL); attributes.insert_cloned_from(&source_attributes, ATTR_APPEARANCE);
attributes.insert_cloned_from(&source_attributes, ATTR_STROKE);
// Adopt the last input item's layer (if any) so the editor can also bucket clicks under a contributing child layer // Adopt the last input item's layer (if any) so the editor can also bucket clicks under a contributing child layer
attributes.insert_cloned_from(&source_attributes, ATTR_EDITOR_LAYER_PATH); attributes.insert_cloned_from(&source_attributes, ATTR_EDITOR_LAYER_PATH);
bake_paint_transforms(&mut attributes, source_transform); bake_paint_transforms(&mut attributes, source_transform);
@@ -1806,15 +1793,7 @@ async fn sample_polyline<V: MapVectorItems + 'n + Send>(
} }
}; };
let mut result = Vector { let mut result = Vector::default();
point_domain: Default::default(),
segment_domain: Default::default(),
region_domain: Default::default(),
colinear_manipulators: Default::default(),
stroke: std::mem::take(&mut content.element_mut().stroke),
};
// Transfer the stroke transform from the input vector content to the result.
result.set_stroke_transform(content.attribute_cloned_or_default(ATTR_TRANSFORM));
for local_bezpath in content.element().stroke_bezpath_iter() { for local_bezpath in content.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)
@@ -1884,10 +1863,7 @@ async fn simplify<V: MapVectorItems + 'n + Send>(
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: std::mem::take(&mut item.element_mut().stroke),
..Default::default()
};
for mut bezpath in item.element().stroke_bezpath_iter() { for mut bezpath in item.element().stroke_bezpath_iter() {
bezpath.apply_affine(transform); bezpath.apply_affine(transform);
@@ -1981,10 +1957,7 @@ async fn decimate<V: MapVectorItems + 'n + Send>(
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: std::mem::take(&mut content.element_mut().stroke),
..Default::default()
};
for mut bezpath in content.element().stroke_bezpath_iter() { for mut bezpath in content.element().stroke_bezpath_iter() {
bezpath.apply_affine(transform); bezpath.apply_affine(transform);
@@ -2060,10 +2033,7 @@ async fn cut_path<V: MapVectorItems + 'n + Send>(
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(bezpath) = bezpaths.get(index).cloned() { if let Some(bezpath) = bezpaths.get(index).cloned() {
let mut result_vector = Vector { let mut result_vector = Vector::default();
stroke: content.element().stroke.clone(),
..Default::default()
};
for (_, bezpath) in bezpaths.iter().enumerate().filter(|&(i, _)| i != index) { for (_, bezpath) in bezpaths.iter().enumerate().filter(|&(i, _)| i != index) {
result_vector.append_bezpath(bezpath.clone()); result_vector.append_bezpath(bezpath.clone());
@@ -2273,10 +2243,6 @@ async fn scatter_points<V: MapVectorItems + 'n + Send>(
} }
} }
// Transfer the style from the input vector content to the result.
result.stroke = content.element().stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
*content.element_mut() = result; *content.element_mut() = result;
content content
}) })
@@ -2662,6 +2628,48 @@ async fn morph<I: IntoGraphicList>(
graphic.map(List::new_from_element) graphic.map(List::new_from_element)
} }
// 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
let paint = lerp_graphic(source_index.and_then(|index| a.paint_at(index)), target_index.and_then(|index| b.paint_at(index)), time).unwrap_or_default();
result.replace_or_insert(coverage, paint, CoverPlacement::Above);
}
Some(result)
}
let (progression, reverse, distribution) = (progression.into_element(), reverse.into_element(), distribution.into_element()); let (progression, reverse, distribution) = (progression.into_element(), reverse.into_element(), distribution.into_element());
// Preserve original `List<Graphic>` as upstream data so this group layer's nested layers can be edited by the tools. // Preserve original `List<Graphic>` as upstream data so this group layer's nested layers can be edited by the tools.
@@ -2916,35 +2924,12 @@ async fn morph<I: IntoGraphicList>(
return Item::from_parts(endpoint_element.clone(), attributes); return 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 fill_paint = { let appearance = {
let source = graphic_list_at(&content, source_index, ATTR_FILL); let source = content.attribute::<Appearance>(ATTR_APPEARANCE, source_index);
let target = graphic_list_at(&content, target_index, ATTR_FILL); let target = content.attribute::<Appearance>(ATTR_APPEARANCE, target_index);
lerp_graphic(source.as_deref(), target.as_deref(), time) lerp_appearance(source, target, time)
};
let stroke_paint = {
let source = graphic_list_at(&content, source_index, ATTR_STROKE);
let target = graphic_list_at(&content, target_index, ATTR_STROKE);
lerp_graphic(source.as_deref(), target.as_deref(), time)
}; };
// Work directly with manipulator groups, bypassing the BezPath intermediate representation. // Work directly with manipulator groups, bypassing the BezPath intermediate representation.
@@ -3114,11 +3099,8 @@ async fn morph<I: IntoGraphicList>(
} }
item.set_attribute(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content); item.set_attribute(ATTR_EDITOR_MERGED_LAYERS, graphic_list_content);
if let Some(fill) = fill_paint { if let Some(appearance) = appearance {
item.set_attribute(ATTR_FILL, fill); item.set_attribute(ATTR_APPEARANCE, appearance);
}
if let Some(stroke) = stroke_paint {
item.set_attribute(ATTR_STROKE, stroke);
} }
item item
@@ -3904,12 +3886,13 @@ mod test {
v v
}; };
let solid_fill = |color: Color| Appearance::new_single(Coverage::new_fill(), List::new_from_element(color).into_graphic_list());
let item_a = Item::new_from_element(rect()) let item_a = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY) .with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
.with_attribute(ATTR_FILL, List::new_from_element(Color::RED).into_graphic_list()); .with_attribute(ATTR_APPEARANCE, solid_fill(Color::RED));
let item_b = Item::new_from_element(rect()) let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into())) .with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_FILL, List::new_from_element(Color::BLUE).into_graphic_list()); .with_attribute(ATTR_APPEARANCE, solid_fill(Color::BLUE));
let mut content = List::new_from_item(item_a); let mut content = List::new_from_item(item_a);
content.push(item_b); content.push(item_b);
@@ -3925,7 +3908,8 @@ mod test {
.await; .await;
let morphed = List::new_from_item(morphed); let morphed = List::new_from_item(morphed);
let fill = graphic_list_at(&morphed, 0, ATTR_FILL).expect("Morph should keep the fill paint at the midpoint"); let appearance = morphed.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint");
let fill = appearance.first_paint_of(Cover::Fill).expect("Morph should keep the fill paint at the midpoint");
// Interpolated color between red and blue should have >0 value on both R and B // Interpolated color between red and blue should have >0 value on both R and B
let Some(Graphic::Color(colors)) = fill.element(0) else { let Some(Graphic::Color(colors)) = fill.element(0) else {
@@ -3935,6 +3919,64 @@ mod test {
assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}"); assert!(color.r() > 0. && color.b() > 0., "Fill should be a red-to-blue blend, got {color:?}");
} }
#[tokio::test]
async fn morph_pairs_appearance_coverages_by_cover() {
let rect = || {
let mut v = Vector::default();
v.append_bezpath(Rect::new(0., 0., 100., 100.).to_path(DEFAULT_ACCURACY));
v
};
let paint_color = |appearance: &Appearance, cover| {
let paint = appearance.first_paint_of(cover).expect("Morph should keep both paints at the midpoint");
let Some(Graphic::Color(colors)) = paint.element(0) else {
panic!("Expected a solid color paint, got {:?}", paint.element(0));
};
*colors.element(0).expect("Color present")
};
// The two endpoints list their covers in opposite paint orders, which pairing by position would cross
let appearance = |fill: Color, stroke: Color, stroke_placement| {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), List::new_from_element(fill).into_graphic_list(), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(4.)), List::new_from_element(stroke).into_graphic_list(), stroke_placement);
appearance
};
let item_a = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
.with_attribute(ATTR_APPEARANCE, appearance(Color::RED, Color::BLACK, CoverPlacement::Above));
let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_APPEARANCE, appearance(Color::BLUE, Color::WHITE, CoverPlacement::Below));
let mut content = List::new_from_item(item_a);
content.push(item_b);
let morphed = super::morph(
Footprint::default(),
content,
Item::new_from_element(0.5),
Item::new_from_element(false),
Item::new_from_element(InterpolationDistribution::default()),
Item::default(),
)
.await;
let morphed = List::new_from_item(morphed);
let appearance = morphed.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("Morph should keep the appearance at the midpoint");
assert_eq!(appearance.len(), 2, "the midpoint should hold one fill and one stroke, not a duplicated cover");
let fill = paint_color(appearance, Cover::Fill);
assert!(fill.r() > 0. && fill.b() > 0. && fill.g() == 0., "the fill should interpolate against the other fill, got {fill:?}");
let stroke = paint_color(appearance, Cover::Stroke);
assert!(
stroke.r() > 0. && stroke.r() == stroke.g() && stroke.g() == stroke.b(),
"the stroke should interpolate against the other stroke, got {stroke:?}"
);
}
#[track_caller] #[track_caller]
fn contains_segment(vector: Vector, target: PathSeg) { fn contains_segment(vector: Vector, target: PathSeg) {
let segments = vector.segment_iter().map(|x| x.1); let segments = vector.segment_iter().map(|x| x.1);