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

Reimplemented on the record model in place of upstream d117c3eace.

Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
Keavon Chambers
2026-09-15 14:49:57 +02:00
committed by Dennis Kobert
co-authored by Dennis Kobert
parent 28e5d1bdc9
commit a41b362fe2
44 changed files with 2087 additions and 1021 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
@@ -37,8 +37,8 @@ pub enum PathStep {
#[derive(PartialEq, Eq, Clone, Copy, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum VectorTableTab {
#[default]
Properties,
Points,
Segments,
Regions,
Handles,
}
@@ -585,7 +585,7 @@ impl TableItemLayout for Vector {
)
}
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()
.map(|tab| {
RadioEntryData::new(format!("{tab:?}"))
@@ -597,61 +597,6 @@ impl TableItemLayout for Vector {
let mut table_rows = Vec::new();
match data.vector_table_tab {
VectorTableTab::Properties => {
table_rows.push(column_headings(&["property", "value"]));
if let Some(stroke) = self.stroke.as_ref() {
table_rows.push(vec![
TextLabel::new("Stroke Weight").narrow(true).widget_instance(),
TextLabel::new(format!("{} px", stroke.weight)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Dash Lengths").narrow(true).widget_instance(),
TextLabel::new(if stroke.dash_lengths.is_empty() {
"-".to_string()
} else {
format!("[{}]", stroke.dash_lengths.iter().map(|x| format!("{x} px")).collect::<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 => {
table_rows.push(column_headings(&["", "position"]));
table_rows.extend(self.point_domain.iter().map(|(id, position)| {
@@ -683,6 +628,16 @@ impl TableItemLayout for Vector {
]
}));
}
VectorTableTab::Handles => {
table_rows.push(column_headings(&["", "colinear_manipulators[0]", "colinear_manipulators[1]"]));
table_rows.extend(self.colinear_manipulators.iter().enumerate().map(|(index, [a, b])| {
vec![
TextLabel::new(format!("{index}")).narrow(true).widget_instance(),
TextLabel::new(format!("{a}")).narrow(true).widget_instance(),
TextLabel::new(format!("{b}")).narrow(true).widget_instance(),
]
}));
}
}
vec![LayoutGroup::row(table_tabs), LayoutGroup::table(table_rows, false)]
@@ -11,9 +11,8 @@ use crate::messages::portfolio::utility_types::PanelType;
use crate::messages::prelude::*;
use glam::{DAffine2, IVec2};
use graph_craft::document::NodeId;
use graphene_std::Appearance;
use graphene_std::Color;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::raster::BlendMode;
use graphene_std::raster::Image;
use graphene_std::transform::Footprint;
@@ -245,14 +244,10 @@ pub enum DocumentMessage {
vector_data: HashMap<NodeId, Arc<Vector>>,
},
// `Message` is only serialized at `editor_wrapper.rs`, and only inputs from JS pass through it.
// `UpdateFillAttributes` and `UpdateStrokeAttributes` are produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point.
// `UpdateAppearanceAttributes` is produced inside `editor.handle_message` by `node_graph_executor.rs` and consumed in the same dispatch loop, so it never reaches that serialization point.
#[serde(skip)]
UpdateFillAttributes {
fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
},
#[serde(skip)]
UpdateStrokeAttributes {
stroke_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
UpdateAppearanceAttributes {
appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
},
Undo,
UngroupSelectedLayers,
@@ -37,7 +37,7 @@ use graph_craft::application_io::wgpu_available;
use graph_craft::descriptor;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{NodeId, NodeInput, NodeNetwork, OldNodeNetwork};
use graphene_std::graphic::is_paint_present;
use graphene_std::Cover;
use graphene_std::math::quad::Quad;
use graphene_std::path_bool_nodes::boolean_intersect;
use graphene_std::raster::BlendMode;
@@ -1502,9 +1502,9 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
.collect();
self.network_interface.update_vector_data(layer_vector_data);
}
DocumentMessage::UpdateFillAttributes { fill_attributes } => {
DocumentMessage::UpdateAppearanceAttributes { appearance_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_fill_attributes = fill_attributes
let layer_appearance_attributes = appearance_attributes
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| {
@@ -1514,21 +1514,7 @@ impl MessageHandler<DocumentMessage, DocumentMessageContext<'_>> for DocumentMes
})
})
.collect();
self.network_interface.update_fill_attributes(layer_fill_attributes);
}
DocumentMessage::UpdateStrokeAttributes { stroke_attributes } => {
// Convert NodeId keys to LayerNodeIdentifier keys, filtering to only layers
let layer_stroke_attributes = stroke_attributes
.into_iter()
.filter(|(node_id, _)| self.network_interface.document_network().nodes.contains_key(node_id))
.filter_map(|(node_id, attrs)| {
self.network_interface.is_layer(&node_id, &[]).then(|| {
let layer = LayerNodeIdentifier::new(node_id, &self.network_interface);
(layer, attrs)
})
})
.collect();
self.network_interface.update_stroke_attributes(layer_stroke_attributes);
self.network_interface.update_appearance_attributes(layer_appearance_attributes);
}
DocumentMessage::Undo => {
if self.network_interface.transaction_status() != TransactionStatus::Finished {
@@ -2763,20 +2749,19 @@ impl DocumentMessageHandler {
let mut resulting_layers: Vec<NodeId> = Vec::new();
for layer in selected_layers {
let Some(vector_data) = self.network_interface.document_metadata().layer_vector_data.get(&layer) else {
if !self.network_interface.document_metadata().layer_vector_data.contains_key(&layer) {
resulting_layers.push(layer.to_node());
continue;
};
let stroke = vector_data.stroke.as_ref();
}
let fill_graphic_list = self.network_interface.document_metadata().layer_fill_attributes.get(&layer);
let stroke_graphic_list = self.network_interface.document_metadata().layer_stroke_attributes.get(&layer);
let appearance = self.network_interface.document_metadata().layer_appearance_attributes.get(&layer);
let has_fill = fill_graphic_list.is_some_and(|list| is_paint_present(list));
// `Vector.stroke` captures stroke geometry, even with weight 0 or transparent paint.
// So stroke visibility must be checked from `ATTR_STROKE`, the paint source of truth.
let stroke_visible = stroke_graphic_list.is_some_and(|list| list.element(0).is_some_and(|g| !g.is_fully_transparent()));
let has_stroke = stroke.as_ref().is_some_and(|s| s.has_renderable_stroke()) && stroke_visible;
let has_fill = appearance.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill));
// A visible stroke needs both renderable geometry (non-zero weight) and paint that draws something
let has_stroke = appearance.is_some_and(|appearance| {
appearance.first_coverage_of(Cover::Stroke).is_some_and(|coverage| coverage.stroke_params().has_renderable_stroke())
&& appearance.first_paint_of(Cover::Stroke).is_some_and(|paint| !paint.is_fully_transparent())
});
// No stroke means there's nothing to solidify. Fill-only layers are already in the desired form, so skip.
if !has_stroke {
@@ -10,7 +10,7 @@ use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath;
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};
#[impl_message(Message, DocumentMessage, GraphOperation)]
@@ -93,6 +93,10 @@ pub enum GraphOperationMessage {
color: Option<Color>,
stroke: Stroke,
},
StrokePaintOrderSet {
layer: LayerNodeIdentifier,
paint_order: PaintOrder,
},
TransformChange {
layer: LayerNodeIdentifier,
transform: DAffine2,
@@ -14,7 +14,7 @@ use graph_craft::document::{NodeId, NodeInput};
use graphene_std::list::List;
use graphene_std::renderer::convert_usvg_path::convert_usvg_path;
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};
#[derive(ExtractField)]
@@ -132,6 +132,14 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageContext<'_>> for
modify_inputs.stroke_set(color, stroke);
}
}
GraphOperationMessage::StrokePaintOrderSet { layer, paint_order } => {
if let Some(stroke_node_id) = crate::messages::tool::common_functionality::graph_modification_utils::get_stroke_id(layer, network_interface)
&& super::utility_types::set_stroke_paint_order(network_interface, &[], stroke_node_id, paint_order)
{
responses.add(PropertiesPanelMessage::Refresh);
responses.add(NodeGraphMessage::RunDocumentGraph);
}
}
GraphOperationMessage::TransformChange {
layer,
transform,
@@ -945,7 +953,6 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont
},
join_miter_limit: stroke.miterlimit().get() as f64,
align: StrokeAlign::Center,
paint_order: PaintOrder::StrokeAbove,
transform,
},
)
@@ -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,
};
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::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,
@@ -19,7 +19,7 @@ use graphene_std::raster::BlendMode;
use graphene_std::raster_types::Image;
use graphene_std::subpath::Subpath;
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::{Artboard, Color, Graphic};
@@ -858,8 +858,6 @@ impl<'a> ModifyInputsContext<'a> {
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);
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);
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);
@@ -1007,3 +1005,63 @@ 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()
}
/// The fill wired directly to the stroke on either side, and whether the stroke currently paints above it.
/// The downstream node of the pair appends its cover last, so it paints on top.
pub fn adjacent_fill_of_stroke(network_interface: &mut NodeNetworkInterface, network_path: &[NodeId], stroke_node_id: NodeId) -> Option<(NodeId, 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) == Some(fill_reference.clone());
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,
};
if let Some(source) = stroke_primary_source.filter(|source| is_fill(network_interface, source)) {
return Some((source, true));
}
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,
});
fill_consumer.map(|fill_node_id| (fill_node_id, false))
}
/// 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 Some((fill_node_id, currently_above)) = adjacent_fill_of_stroke(network_interface, network_path, stroke_node_id) else {
return false;
};
if (paint_order == PaintOrder::StrokeAbove) == currently_above {
return false;
}
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
}
@@ -2680,9 +2680,6 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
ParameterWidgetsInfo::new(node_id, MiterLimitInput, true, context),
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 dash_lengths = dash_pattern_widget(ParameterWidgetsInfo::new(node_id, DashPatternInput, true, context), TextInput::default().centered(true));
let number_input = disabled_number_input;
@@ -2695,7 +2692,6 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
cap,
join,
LayoutGroup::row(miter_limit),
paint_order,
LayoutGroup::row(dash_lengths),
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 glam::{DAffine2, DVec2};
use graph_craft::document::NodeId;
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::Appearance;
use graphene_std::math::quad::Quad;
use graphene_std::subpath;
use graphene_std::transform::Footprint;
@@ -41,12 +40,9 @@ pub struct DocumentMetadata {
/// Vector data keyed by layer ID, used as fallback when no Path node exists.
/// This provides accurate SegmentIds for layers without explicit Path nodes.
pub layer_vector_data: HashMap<LayerNodeIdentifier, Arc<Vector>>,
/// Per-layer `ATTR_FILL` attribute, exposed so message handlers can read paint
/// information that lives on the list.
pub layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>,
/// 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<'static>>>>,
/// Per-layer resolved appearance snapshot, exposed so message handlers can
/// read the paint and stroke parameters that live on the coverage rows.
pub layer_appearance_attributes: HashMap<LayerNodeIdentifier, Arc<Appearance>>,
/// Transform from document space to viewport space.
pub document_to_viewport: DAffine2,
}
@@ -233,10 +229,15 @@ impl DocumentMetadata {
/// stroke geometry when the layer is a vector with a stroke style. Falls back to the click-target-based
/// bounds for non-vector layers (groups, raster, text, color, gradient).
pub fn bounding_box_document_with_stroke(&self, layer: LayerNodeIdentifier) -> Option<[DVec2; 2]> {
if let Some(vector) = self.layer_vector_data.get(&layer)
&& let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer))
{
return Some(bounds);
if let Some(vector) = self.layer_vector_data.get(&layer) {
let stroke = self
.layer_appearance_attributes
.get(&layer)
.and_then(|appearance| appearance.first_coverage_of(graphene_std::Cover::Stroke))
.map(graphene_std::Coverage::stroke_params);
if let Some(bounds) = vector.stroke_inclusive_bounding_box_with_transform(self.transform_to_document(layer), stroke.as_ref()) {
return Some(bounds);
}
}
self.bounding_box_document(layer)
}
@@ -40,8 +40,6 @@ use graph_craft::Type;
use graph_craft::application_io::resource::ResourceId;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNode, DocumentNodeImplementation, NodeId, NodeInput, NodeNetwork, OldDocumentNodeImplementation, OldNodeNetwork};
use graphene_std::Graphic;
use graphene_std::list::List;
use graphene_std::math::quad::Quad;
use graphene_std::subpath::Subpath;
use graphene_std::transform::Footprint;
@@ -183,13 +183,8 @@ impl NodeNetworkInterface {
self.document_metadata.layer_vector_data = new_layer_vector_data;
}
/// Update the per-layer `ATTR_FILL` snapshot.
pub fn update_fill_attributes(&mut self, new_layer_fill_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>) {
self.document_metadata.layer_fill_attributes = new_layer_fill_attributes;
}
/// Update the per-layer `ATTR_STROKE` snapshot.
pub fn update_stroke_attributes(&mut self, new_layer_stroke_attributes: HashMap<LayerNodeIdentifier, Arc<List<Graphic<'static>>>>) {
self.document_metadata.layer_stroke_attributes = new_layer_stroke_attributes;
/// Update the per-layer resolved appearance snapshot.
pub fn update_appearance_attributes(&mut self, new_layer_appearance_attributes: HashMap<LayerNodeIdentifier, Arc<graphene_std::Appearance>>) {
self.document_metadata.layer_appearance_attributes = new_layer_appearance_attributes;
}
}
@@ -1,6 +1,7 @@
// TODO: Eventually remove this document upgrade code
// 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::utility_types::document_metadata::LayerNodeIdentifier;
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeTemplate, NodeTemplateImplementation, OutputConnector};
@@ -1857,13 +1858,12 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
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), landing on the shape without the retired "Paint Order" input
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 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 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, 1), old_inputs[1].clone(), network_path);
@@ -1872,10 +1872,33 @@ 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, 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, 7), paint_order_input, network_path);
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, 9), old_inputs[4].clone(), 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, 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);
}
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 7), old_inputs[8].clone(), 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
@@ -183,12 +183,18 @@ impl DrawingToolState {
cap: self.stroke_cap.unwrap_or_default(),
join: self.stroke_join.unwrap_or_default(),
join_miter_limit: self.miter_limit.unwrap_or(4.),
paint_order: self.paint_order.unwrap_or_default(),
dash_lengths: self.effective_dash_lengths(),
dash_offset: self.dash_offset.unwrap_or(0.),
transform: glam::DAffine2::IDENTITY,
};
responses.add(GraphOperationMessage::StrokeSet { layer, color, stroke });
// The paint order is the chain order of the Fill and Stroke nodes, so a below choice reorders the new pair
if self.paint_order.unwrap_or_default() == graphene_std::vector::style::PaintOrder::StrokeBelow {
responses.add(GraphOperationMessage::StrokePaintOrderSet {
layer,
paint_order: graphene_std::vector::style::PaintOrder::StrokeBelow,
});
}
}
}
@@ -692,9 +692,22 @@ pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNe
Some(TaggedValue::F64(value)) => *value,
_ => 4.,
};
let paint_order = match parameters.value(stroke::PaintOrderInput) {
Some(TaggedValue::PaintOrder(value)) => *value,
_ => PaintOrder::default(),
// The paint order is the chain order of the Fill and Stroke nodes: the downstream one of an adjacent pair paints on top
let paint_order = {
let stroke_node_id = get_stroke_id(layer, network_interface);
let primary_source = |node_id: Option<NodeId>| {
node_id.and_then(|node_id| 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,
})
};
if primary_source(stroke_node_id).is_some_and(|source| Some(source) == get_fill_id(layer, network_interface)) {
PaintOrder::StrokeAbove
} else if primary_source(get_fill_id(layer, network_interface)).is_some_and(|source| Some(source) == stroke_node_id) {
PaintOrder::StrokeBelow
} else {
PaintOrder::default()
}
};
let dash_lengths = match parameters.value(stroke::DashPatternInput) {
Some(TaggedValue::DashPattern(lengths)) => lengths.clone(),
@@ -198,7 +198,10 @@ 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>) {
drawing.paint_order = Some(order);
graph_modification_utils::set_parameter_for_selected_layers(document, graphene_std::vector::stroke::PaintOrderInput, TaggedValue::PaintOrder(order), responses);
// The paint order is the chain order of the Fill and Stroke nodes, so applying it reorders the pair
for layer in document.network_interface.selected_nodes().selected_layers_except_artboards(&document.network_interface) {
responses.add(GraphOperationMessage::StrokePaintOrderSet { layer, paint_order: order });
}
}
pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec<f64>, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
+2 -4
View File
@@ -712,8 +712,7 @@ impl NodeGraphExecutor {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
appearance_attributes,
backgrounds: _,
} = render_output.metadata;
@@ -728,8 +727,7 @@ impl NodeGraphExecutor {
responses.add(DocumentMessage::UpdateTextFrames { text_frames });
responses.add(DocumentMessage::UpdateClipTargets { clip_targets });
responses.add(DocumentMessage::UpdateVectorData { vector_data });
responses.add(DocumentMessage::UpdateFillAttributes { fill_attributes });
responses.add(DocumentMessage::UpdateStrokeAttributes { stroke_attributes });
responses.add(DocumentMessage::UpdateAppearanceAttributes { appearance_attributes });
responses.add(DocumentMessage::RenderScrollbars);
responses.add(DocumentMessage::RenderRulers);
responses.add(OverlaysMessage::Draw);
@@ -0,0 +1,417 @@
//! 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.
//!
//! The interior is `'static`: the paint column stores `Graphic<'static>`.
use crate::Graphic;
use crate::markers::ATTR_PAINT;
use core_types::ATTR_TRANSFORM;
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
use vector_types::markers::{ATTR_ALIGN, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_WEIGHT};
use vector_types::vector::style::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, dyn_any::DynAny)]
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, dyn_any::DynAny)]
pub struct Coverage(pub Item<Cover>);
// Item equality ignores attributes, but the stroke parameters live there.
impl PartialEq for Coverage {
fn eq(&self, other: &Self) -> bool {
self.0.element() == other.0.element()
&& self
.0
.attributes()
.iter()
.map(|(key, value)| (key, value.display_string()))
.eq(other.0.attributes().iter().map(|(key, value)| (key, value.display_string())))
}
}
impl CacheHash for Coverage {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.0.element().cache_hash(state);
for (key, value) in self.0.attributes().iter() {
std::hash::Hash::hash(key, state);
std::hash::Hash::hash(&value.display_string(), state);
}
}
}
/// 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, dyn_any::DynAny)]
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, 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 {
let mut stroke = Stroke::default();
for (key, value) in self.0.attributes().iter() {
match key {
ATTR_WEIGHT => stroke.weight = value.as_any().downcast_ref().copied().unwrap_or(stroke.weight),
ATTR_DASH_PATTERN => {
stroke.dash_lengths = value
.as_any()
.downcast_ref::<Vec<f64>>()
.map(|lengths| lengths.iter().map(|length| length.max(0.)).collect())
.unwrap_or(stroke.dash_lengths)
}
ATTR_DASH_OFFSET => stroke.dash_offset = value.as_any().downcast_ref().copied().unwrap_or(stroke.dash_offset),
ATTR_CAP => stroke.cap = value.as_any().downcast_ref().copied().unwrap_or(stroke.cap),
ATTR_JOIN => stroke.join = value.as_any().downcast_ref().copied().unwrap_or(stroke.join),
ATTR_JOIN_MITER_LIMIT => stroke.join_miter_limit = value.as_any().downcast_ref().copied().unwrap_or(stroke.join_miter_limit),
ATTR_ALIGN => stroke.align = value.as_any().downcast_ref().copied().unwrap_or(stroke.align),
ATTR_TRANSFORM => stroke.transform = value.as_any().downcast_ref().copied().unwrap_or(stroke.transform),
_ => {}
}
}
stroke
}
}
/// Builds an appearance row, eliding the paint attribute when it is the default none-paint.
fn cover_row(coverage: Coverage, paint: Graphic<'static>) -> Item<Coverage> {
let mut row = Item::new_from_element(coverage);
if paint != Graphic::default() {
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: Graphic<'static>) -> 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<&Graphic<'static>> {
self.0.attribute::<Graphic<'static>>(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<&Graphic<'static>> {
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| !paint.is_empty())
}
/// 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<&Graphic<'static>>)> {
self.covers().enumerate().map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| !paint.is_empty())))
}
/// 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| !paint.is_empty());
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 the none-paint 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(|paint| !paint.is_empty()))
}
/// 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: Graphic<'static>, 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 => {
self.0 = std::iter::once(row).chain(std::mem::take(&mut self.0)).collect();
}
}
}
/// 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: Graphic<'static>) -> 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 Graphic<'static>>,
pub stroke_paint: Option<&'a Graphic<'static>>,
/// 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: Graphic<'static>, placement: CoverPlacement) {
item.attribute_mut_or_insert_default::<Appearance>(crate::markers::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) -> Graphic<'static> {
Graphic::Color(color)
}
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
let paint = appearance.paint_at(index)?;
let Graphic::Color(color) = paint else { return None };
Some(*color)
}
#[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(), Graphic::default(), 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));
}
}
@@ -1,6 +1,7 @@
//! The record-crossing glue: group interiors carried between the owned, resident, and persistent regions.
use super::Graphic;
use crate::appearance::Appearance;
use core_types::Color;
use core_types::list::{Item, List};
use raster_types::{CPU, Raster};
@@ -110,12 +111,18 @@ fn attribute_keys(list: &List<Graphic>) -> Vec<String> {
pub(crate) fn map_attribute_groups_to_owned(list: &mut List<Graphic<'_>>) {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_owned(element);
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_owned(element);
}
map_attribute_groups_to_owned(value);
}
map_attribute_groups_to_owned(value);
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_owned(appearance);
}
}
}
@@ -125,15 +132,21 @@ pub(crate) fn map_attribute_groups_to_owned(list: &mut List<Graphic<'_>>) {
pub(crate) fn map_attribute_groups_to_resident(list: &mut List<Graphic<'_>>, arena: &core_types::arena::Arena) -> Option<()> {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let resident = map_groups_to_resident(element, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*element = unsafe { core_types::record::erase_static(resident) };
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let resident = map_groups_to_resident(element, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*element = unsafe { core_types::record::erase_static(resident) };
}
map_attribute_groups_to_resident(value, arena)?;
}
map_attribute_groups_to_resident(value, arena)?;
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_resident(appearance, arena)?;
}
}
Some(())
@@ -145,15 +158,21 @@ pub(crate) fn map_attribute_groups_to_resident(list: &mut List<Graphic<'_>>, are
pub(crate) fn map_attribute_groups_to_persistent(list: &mut List<Graphic<'_>>, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
for key in attribute_keys(list) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) else { continue };
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let promoted = map_groups_to_persistent(element, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*element = unsafe { core_types::record::erase_static(promoted) };
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(&key) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
let promoted = map_groups_to_persistent(element, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*element = unsafe { core_types::record::erase_static(promoted) };
}
map_attribute_groups_to_persistent(value, promotion)?;
}
map_attribute_groups_to_persistent(value, promotion)?;
continue;
}
let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(&key) else { continue };
for appearance in appearances {
map_appearance_groups_to_persistent(appearance, promotion)?;
}
}
Some(())
@@ -232,13 +251,14 @@ pub(crate) fn list_contains_groups(list: &List<Graphic>) -> bool {
/// Whether any group hides in the list's item attribute values. Only the
/// group-capable columns are scanned: those are the value types the deep field
/// glue is registered for, today `Option<List<Graphic>>` alone. A list with no
/// attribute columns costs nothing, and a column of any other type is decided
/// by its one downcast rather than per value.
/// glue is registered for, `Option<List<Graphic>>` and `Appearance`. A list
/// with no attribute columns costs nothing, and a column of any other type is
/// decided by its one downcast rather than per value.
fn attribute_values_contain_groups(list: &List<Graphic>) -> bool {
list.attribute_keys().any(|key| {
list.iter_attribute_values::<Option<List<Graphic>>>(key)
.is_some_and(|mut values| values.any(|value| value.as_ref().is_some_and(list_contains_groups)))
|| list.iter_attribute_values::<Appearance>(key).is_some_and(|mut values| values.any(appearance_contains_groups))
})
}
@@ -332,12 +352,143 @@ unsafe fn promote_graphic_list(src: *const u8, dst: *mut u8, promotion: &core_ty
Some(())
}
/// The coverage list's attribute keys, owned so the columns can be walked mutably.
fn appearance_attribute_keys(appearance: &Appearance) -> Vec<String> {
appearance.0.attribute_keys().map(str::to_string).collect()
}
/// Whether any group hides in an appearance. Groups can only arrive through the
/// `Graphic`-typed columns of the coverage list, today the paint column alone;
/// a coverage item's own attributes hold plain stroke parameters.
fn appearance_contains_groups(appearance: &Appearance) -> bool {
appearance
.0
.attribute_keys()
.any(|key| appearance.0.iter_attribute_values::<Graphic>(key).is_some_and(|mut values| values.any(graphic_contains_groups)))
}
/// The heap an appearance's paints own, group interiors excluded as
/// [`graphic_retained_heap`] excludes them.
fn appearance_retained_heap(appearance: &Appearance) -> usize {
appearance
.0
.attribute_keys()
.map(|key| appearance.0.iter_attribute_values::<Graphic>(key).map_or(0, |values| values.map(graphic_retained_heap).sum()))
.sum()
}
/// Every group held in an appearance's paint columns, deep-copied to its owned form.
fn map_appearance_groups_to_owned(appearance: &mut Appearance) {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
*value = map_groups_to_owned(value);
}
}
}
/// Every owned group held in an appearance's paint columns, re-parked into
/// `arena`. `None` reports arena exhaustion.
fn map_appearance_groups_to_resident(appearance: &mut Appearance, arena: &core_types::arena::Arena) -> Option<()> {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
let resident = map_groups_to_resident(value, arena)?;
// SAFETY: the attribute store is erased, and the replay serves as
// long as `arena`, which the store's reader outlives.
*value = unsafe { core_types::record::erase_static(resident) };
}
}
Some(())
}
/// The deep copy-out for appearance field values (the appearance marker's bare
/// owned form): content groups leave any paint column in their owned form.
/// Declines (`None`) for group-free content, which already owns everything.
fn deep_clone_appearance(value: &dyn core_types::list::AnyAttributeValue) -> Option<Box<dyn core_types::list::AnyAttributeValue>> {
let appearance = value.as_any().downcast_ref::<Appearance>().expect("an appearance field deep-copies at its own type");
appearance_contains_groups(appearance).then_some(())?;
let mut appearance = appearance.clone();
map_appearance_groups_to_owned(&mut appearance);
Some(Box::new(appearance))
}
/// The deep replay for appearance field values: owned content groups replay
/// into the serving arena before the field re-parks. `Some(None)` declines for
/// group-free content; `None` reports arena exhaustion.
fn deep_repark_appearance(value: &dyn core_types::list::AnyAttributeValue, arena: &core_types::arena::Arena) -> Option<Option<Box<dyn core_types::list::AnyAttributeValue>>> {
let appearance = value.as_any().downcast_ref::<Appearance>().expect("an appearance field replays at its own type");
if !appearance_contains_groups(appearance) {
return Some(None);
}
let mut appearance = appearance.clone();
map_appearance_groups_to_resident(&mut appearance, arena)?;
Some(Some(Box::new(appearance)))
}
/// Every group held in an appearance's paint columns, promoted into the
/// persistent region on the same Cow dispatch the elements take. `None`
/// reports arena exhaustion.
fn map_appearance_groups_to_persistent(appearance: &mut Appearance, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
for key in appearance_attribute_keys(appearance) {
// A column of a type that cannot hold groups is skipped whole.
let Some(values) = appearance.0.iter_attribute_values_mut::<Graphic>(&key) else { continue };
for value in values {
let promoted = map_groups_to_persistent(value, promotion)?;
// SAFETY: the attribute store is erased, and persistent content
// outlives the evaluation.
*value = unsafe { core_types::record::erase_static(promoted) };
}
}
Some(())
}
/// The promote for appearance fields, retargeting the paint list promote onto
/// the coverage container: the header moves where no group is reachable from
/// any paint column, and otherwise clones into a fresh persistent park, with
/// content groups taking [`map_groups_to_persistent`]'s Cow dispatch one level
/// inside, exactly as [`promote_graphic_list`] dispatches for the paint lists.
///
/// # Safety
/// `src` must point at a live parked appearance field, and `dst` at the field
/// slot the promoted reference is written to.
unsafe fn promote_appearance(src: *const u8, dst: *mut u8, promotion: &core_types::record::Promotion<'_>) -> Option<()> {
// SAFETY: the caller's contract; the slot holds one optional reference.
let Some(appearance) = (unsafe { src.cast::<Option<&Appearance>>().read() }) else {
// SAFETY: as above, into the promoted image's own field slot.
unsafe { dst.cast::<Option<&Appearance>>().write(None) };
return Some(());
};
let retained = appearance_retained_heap(appearance);
if !appearance_contains_groups(appearance) {
// SAFETY: an appearance no group is reachable from owns all of its
// content, and the arena declines a reference that is not a park at the
// appearance's own address and size.
if let Some(moved) = unsafe { promotion.move_park::<Appearance>(std::ptr::from_ref(appearance).cast(), retained) } {
// SAFETY: the move published a live appearance in the persistent region.
unsafe { dst.cast::<Option<&Appearance>>().write(Some(&*moved)) };
return Some(());
}
}
let mut promoted = appearance.clone();
map_appearance_groups_to_persistent(&mut promoted, promotion)?;
let (parked, _) = promotion.persistent().alloc_sized(promoted, retained)?;
// SAFETY: the slot holds one optional reference.
unsafe { dst.cast::<Option<&Appearance>>().write(Some(parked)) };
Some(())
}
const _: () = {
fn register_all() {
core_types::record::register_deep_element_clone::<Graphic>(deep_clone_graphic, deep_repark_graphic);
core_types::record::register_deep_field_value::<Option<List<Graphic>>>(deep_clone_graphic_list, deep_repark_graphic_list);
core_types::record::register_field_promote::<Option<&'static List<Graphic<'static>>>>(promote_graphic_list);
core_types::record::register_element_promote::<Graphic>(promote_graphic);
core_types::record::register_deep_field_value::<Appearance>(deep_clone_appearance, deep_repark_appearance);
core_types::record::register_field_promote::<Option<&'static Appearance>>(promote_appearance);
core_types::record::register_retained_heap::<Appearance>(|value| value.downcast_ref::<Appearance>().map_or(0, appearance_retained_heap));
core_types::record::register_retained_heap::<Graphic>(|value| value.downcast_ref::<Graphic>().map_or(0, graphic_retained_heap));
core_types::record::register_retained_heap::<Vector>(|value| value.downcast_ref::<Vector>().map_or(0, vector_retained_heap));
core_types::record::register_retained_heap::<Raster<CPU>>(|value| value.downcast_ref::<Raster<CPU>>().map_or(0, |raster| raster.data.len() * size_of::<Color>()));
@@ -362,7 +513,7 @@ mod run_tests {
use super::*;
use crate::graphic::test_support::{native_group_paint, unit_square_at};
use crate::graphic::{group_to_legacy_list, map_groups_to_legacy};
use crate::markers::{Fill, Stroke};
use crate::markers::EditorMergedLayers;
use core_types::attribute::Attribute;
use core_types::lane::LaneSource;
use core_types::record::{FieldWrite, RunBuilder, RunView, element_write_hashed};
@@ -374,9 +525,9 @@ mod run_tests {
let vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let group = core_types::record::Group { row: None, content: builder.finish() };
let expected = group_to_legacy_list(&group);
let owned = map_groups_to_owned(&Graphic::Group(group));
@@ -418,9 +569,9 @@ mod run_tests {
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let owned = item.copy_out();
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -431,7 +582,7 @@ mod run_tests {
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let replayed = owned.replay(&arena).expect("the arena holds the replay");
let run = RunView::<Vector>::new(&replayed).expect("the run holds vector elements");
let served = run.attr::<Fill>(0).expect("the fill replays present");
let served = run.attr::<EditorMergedLayers>(0).expect("the fill replays present");
assert_eq!(map_groups_to_legacy(served.element(0).unwrap()), expected);
}
@@ -444,12 +595,12 @@ mod run_tests {
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -479,8 +630,8 @@ mod run_tests {
) -> (core_types::record::Layout, core_types::record::MaterializedSpan, Vec<u64>) {
use core_types::record::{Layout, MaterializedSpan, Promotion, element_write, write_field};
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<Fill>(0)]);
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<EditorMergedLayers>(0)]);
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
let stride = layout.lane_stride();
let mut buffer = vec![0u64; (lanes * stride).div_ceil(8)];
let base = buffer.as_mut_ptr().cast::<u8>();
@@ -503,7 +654,7 @@ mod run_tests {
/// The promoted paint of one lane, at the layout the promote published.
fn promoted_paint<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p List<Graphic<'p>> {
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
let batch = span.batch(persistent, layout).expect("the span resolves in its own region");
// SAFETY: the promote wrote a record of `layout` into every lane.
unsafe { batch.get(lane).rec().read::<Option<&List<Graphic>>>(offset) }.expect("the paint promotes present")
@@ -583,7 +734,7 @@ mod run_tests {
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(4., 4.))));
// SAFETY: the erased native list serves only while `transient` is live; the
// promote under test replaces its borrows.
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(unsafe { core_types::record::erase_static(native) }));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(unsafe { core_types::record::erase_static(native) }));
let (paint, _) = transient.alloc_sized_keyed(paint, 0).unwrap();
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
@@ -593,7 +744,7 @@ mod run_tests {
transient.reset();
let served = promoted_paint(&span, &layout, 0, &persistent);
let held = served.attribute::<Option<List<Graphic>>>(Stroke::NAME, 0).expect("the stroke attribute rides the promoted list");
let held = served.attribute::<Option<List<Graphic>>>("probe:paint", 0).expect("the stroke attribute rides the promoted list");
let held = held.as_ref().expect("the stroke is present");
assert_eq!(
map_groups_to_legacy(held.element(0).unwrap()),
@@ -612,7 +763,7 @@ mod run_tests {
// whose value holds none: neither denies the move.
paint.set_attribute::<f64>("opacity", 0, 0.5);
paint.set_attribute::<Color>("probe:color", 0, Color::BLACK);
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(List::new_from_element(Graphic::Color(Color::WHITE))));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(List::new_from_element(Graphic::Color(Color::WHITE))));
let heap = {
let Some(Graphic::Vector(vector)) = paint.element(0) else { panic!("the paint carries a vector") };
vector.point_domain.positions().as_ptr()
@@ -647,15 +798,15 @@ mod run_tests {
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
// SAFETY: the erased native list serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
paint.set_attribute::<Option<List<Graphic>>>(Stroke::NAME, 0, Some(unsafe { core_types::record::erase_static(native) }));
paint.set_attribute::<Option<List<Graphic>>>("probe:paint", 0, Some(unsafe { core_types::record::erase_static(native) }));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(Fill::NAME, 0).unwrap();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
drop(item);
@@ -670,8 +821,244 @@ mod run_tests {
let value = unsafe { slot.finish() };
// SAFETY: the replay wrote a record of `layout`.
let served = unsafe { layout.rec(&value).read::<Option<&List<Graphic>>>(offset) }.expect("the fill replays present");
let held = served.attribute::<Option<List<Graphic>>>(Stroke::NAME, 0).expect("the stroke attribute rides the replayed list");
let held = served.attribute::<Option<List<Graphic>>>("probe:paint", 0).expect("the stroke attribute rides the replayed list");
let held = held.as_ref().expect("the stroke is present");
assert_eq!(map_groups_to_legacy(held.element(0).unwrap()), expected, "the attribute-held group replayed into the serving arena");
}
use crate::appearance::{Cover, CoverPlacement, Coverage};
use crate::markers::Appearance as AppearanceMarker;
/// A `lanes`-long frame promoted out of `transient` into `persistent`, with
/// `appearance` written into the appearance field of every lane. The frame
/// buffer comes back so it outlives the promote's reads.
fn promote_appearance_field(
appearance: Option<&Appearance>,
lanes: usize,
transient: &core_types::arena::Arena,
persistent: &core_types::arena::Arena,
) -> (core_types::record::Layout, core_types::record::MaterializedSpan, Vec<u64>) {
use core_types::record::{Layout, MaterializedSpan, Promotion, element_write, write_field};
let layout = Layout::default().with_writes(0, element_write::<f64>(), &[FieldWrite::of::<AppearanceMarker>(0)]);
let offset = layout.offset_of(AppearanceMarker::NAME, 0).unwrap();
let stride = layout.lane_stride();
let mut buffer = vec![0u64; (lanes * stride).div_ceil(8)];
let base = buffer.as_mut_ptr().cast::<u8>();
let bounds = (base as usize, buffer.len() * 8);
for lane in 0..lanes {
// SAFETY: the frame is this layout's, written at the element slot and
// at the appearance field's own offset.
unsafe {
base.add(lane * stride).cast::<f64>().write(lane as f64);
write_field::<Option<&Appearance>>(base.add(lane * stride), offset, appearance);
}
}
// SAFETY: the frames hold `lanes` live records of `layout`.
let batch = unsafe { core_types::node::RecordBatch::new(base.cast_const(), lanes, &layout) };
let promotion = Promotion::new(transient, bounds, persistent);
// SAFETY: as above.
let span = unsafe { MaterializedSpan::to_persistent(&batch, &promotion) }.expect("the region holds the promote");
(layout, span, buffer)
}
/// The promoted appearance of one lane, at the layout the promote published.
fn promoted_appearance<'p>(span: &core_types::record::MaterializedSpan, layout: &core_types::record::Layout, lane: usize, persistent: &'p core_types::arena::Arena) -> &'p Appearance {
let offset = layout.offset_of(AppearanceMarker::NAME, 0).unwrap();
let batch = span.batch(persistent, layout).expect("the span resolves in its own region");
// SAFETY: the promote wrote a record of `layout` into every lane.
unsafe { batch.get(lane).rec().read::<Option<&Appearance>>(offset) }.expect("the appearance promotes present")
}
#[test]
fn a_promoted_appearance_shares_persistent_interiors() {
let inner_vector = unit_square_at(DVec2::ZERO);
let transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// The interior an upstream promote already published, named by a paint
// the evaluation parked in a coverage's paint column.
let published = native_group_paint(&inner_vector, &persistent);
let Some(Graphic::Group(group)) = published.element(0) else {
panic!("the paint carries a native group")
};
let interior = group.content.lanes().get(0).rec().ptr();
// SAFETY: the group serves only while `persistent` is live, and the
// promote under test replaces every borrow it carries.
let paint = unsafe { core_types::record::erase_static(Graphic::Group(group.clone())) };
let appearance = Appearance::new_single(Coverage::new_fill(), paint);
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Group(group)) = served.paint_at(0) else {
panic!("the promote keeps the group form")
};
assert_eq!(group.content.lanes().get(0).rec().ptr(), interior, "a persistent interior is shared pointer for pointer");
}
#[test]
fn a_group_free_appearance_moves_its_parked_header() {
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Vector(unit_square_at(DVec2::ZERO)));
let heap = {
let Some(Graphic::Vector(vector)) = appearance.paint_at(0) else {
panic!("the paint carries a vector")
};
vector.point_domain.positions().as_ptr()
};
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 2, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.paint_at(0) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
assert!(std::ptr::eq(served, promoted_appearance(&span, &layout, 1, &persistent)), "an appearance two lanes share moves once");
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
assert!(matches!(served.paint_at(0), Some(Graphic::Vector(_))), "the moved appearance survives the transient reset");
}
#[test]
fn an_appearance_whose_paint_holds_groups_never_moves() {
let inner_vector = unit_square_at(DVec2::ZERO);
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
let native = native_group_paint(&inner_vector, &transient);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// SAFETY: the erased native group serves only while `transient` is live;
// the promote under test replaces its borrows.
let paint = unsafe { core_types::record::erase_static(native_group.clone()) };
let appearance = Appearance::new_single(Coverage::new_fill(), paint);
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let moved = std::ptr::eq(std::ptr::from_ref(served).cast::<u8>(), std::ptr::from_ref(appearance).cast::<u8>());
assert!(!moved, "a paint-held group denies the move, so the promote parks a header of its own");
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
let held = served.paint_at(0).expect("the paint rides the promoted appearance");
assert_eq!(map_groups_to_legacy(held), expected, "the paint-held group serves from persistent storage after the reset");
}
#[test]
fn a_group_free_appearance_moves_past_its_stroke_columns() {
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// Stroke parameters on the coverage and a group-free paint: neither denies the move.
let mut appearance = Appearance::new_single(Coverage::new_stroke(&vector_types::vector::style::Stroke::new(2.)), Graphic::Vector(unit_square_at(DVec2::ZERO)));
appearance.replace_or_insert(Coverage::new_fill(), Graphic::Color(Color::WHITE), CoverPlacement::Below);
let heap = {
let Some(Graphic::Vector(vector)) = appearance.paint_at(1) else {
panic!("the stroke paint carries a vector")
};
vector.point_domain.positions().as_ptr()
};
let (appearance, _) = transient.alloc_sized_keyed(appearance, 0).unwrap();
let (layout, span, _frames) = promote_appearance_field(Some(appearance), 1, &transient, &persistent);
let served = promoted_appearance(&span, &layout, 0, &persistent);
let Some(Graphic::Vector(vector)) = served.paint_at(1) else {
panic!("the promote keeps the vector")
};
assert_eq!(
vector.point_domain.positions().as_ptr(),
heap,
"the promote moved the header, so the served paint names the pre-promote heap"
);
transient.reset();
let served = promoted_appearance(&span, &layout, 0, &persistent);
assert_eq!(
served.first_coverage_of(Cover::Stroke).map(|coverage| coverage.stroke_params().weight),
Some(2.),
"the stroke parameters survive the move"
);
}
#[test]
fn a_paint_field_whose_appearance_column_holds_groups_never_moves() {
let inner_vector = unit_square_at(DVec2::ZERO);
let mut transient = core_types::arena::Arena::new(1 << 16).unwrap();
let persistent = core_types::arena::Arena::new(1 << 16).unwrap();
// Group-free elements, with the resident group hidden in an item's appearance paint cell.
let native = native_group_paint(&inner_vector, &transient);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// SAFETY: the erased native group serves only while `transient` is live;
// the promote under test replaces its borrows.
let paint_cell = unsafe { core_types::record::erase_static(native_group.clone()) };
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(4., 4.))));
paint.set_attribute(crate::markers::ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), paint_cell));
let (paint, _) = transient.alloc_sized_keyed(paint, 0).unwrap();
let (layout, span, _frames) = promote_paint_field(Some(paint), 1, &transient, &persistent);
let served = promoted_paint(&span, &layout, 0, &persistent);
let moved = std::ptr::eq(std::ptr::from_ref(served).cast::<u8>(), std::ptr::from_ref(paint).cast::<u8>());
assert!(!moved, "an appearance-held group denies the move, so the promote parks a header of its own");
transient.reset();
let served = promoted_paint(&span, &layout, 0, &persistent);
let held = served.attribute::<Appearance>(crate::markers::ATTR_APPEARANCE, 0).expect("the appearance rides the promoted list");
let held = held.paint_at(0).expect("the fill coverage keeps its paint");
assert_eq!(map_groups_to_legacy(held), expected, "the appearance-held group serves from persistent storage after the reset");
}
#[test]
fn an_owned_record_deep_copies_appearance_column_groups() {
let inner_vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
let native = native_group_paint(&inner_vector, &source);
let Some(native_group) = native.element(0) else { panic!("the paint carries a group") };
let expected = map_groups_to_legacy(native_group);
// The field's elements are group-free; the group rides an item's appearance
// paint cell, which the shallow read alone would leave borrowing `source`.
// SAFETY: the erased native group serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
let paint_cell = unsafe { core_types::record::erase_static(native_group.clone()) };
let mut paint = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
paint.set_attribute(crate::markers::ATTR_APPEARANCE, 0, Appearance::new_single(Coverage::new_fill(), paint_cell));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<EditorMergedLayers>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<EditorMergedLayers>(lane, Some(&paint));
let item = builder.finish();
let layout = item.layout().clone();
let offset = layout.offset_of(EditorMergedLayers::NAME, 0).unwrap();
// SAFETY: the item's lane is a live record of `layout`.
let owned = unsafe { core_types::record::OwnedRecord::copy_out(&layout, item.lanes().get(0).rec()) };
drop(item);
drop(paint);
drop(source);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let frames = core_types::record::test_frames(layout.frame_bytes());
let mut slot = frames.claim(&layout);
owned.replay_into(&mut slot, &arena).expect("the arena holds the replay");
// SAFETY: the replay completes the record in the claimed frame.
let value = unsafe { slot.finish() };
// SAFETY: the replay wrote a record of `layout`.
let served = unsafe { layout.rec(&value).read::<Option<&List<Graphic>>>(offset) }.expect("the field replays present");
let held = served.attribute::<Appearance>(crate::markers::ATTR_APPEARANCE, 0).expect("the appearance rides the replayed list");
let held = held.paint_at(0).expect("the fill coverage keeps its paint");
assert_eq!(map_groups_to_legacy(held), expected, "the appearance-held group replayed into the serving arena");
}
}
@@ -1,8 +1,8 @@
//! The legacy bridge: record-backed groups rebuilt as owned legacy lists.
use super::walk::push_lane_paint_into_interiors;
use super::{Graphic, detable_items};
use crate::markers::{ATTR_FILL, ATTR_STROKE};
use crate::appearance::Appearance;
use crate::markers::{ATTR_APPEARANCE, ATTR_EDITOR_MERGED_LAYERS, ATTR_PAINT};
use core_types::Color;
use core_types::list::{Item, List};
use raster_types::{CPU, GPU, Raster};
@@ -15,11 +15,19 @@ pub fn run_to_list<T: Clone + Send + Sync + dyn_any::StaticTypeSized>(item: &cor
core_types::record::run_to_owned_list(item)
}
/// Converts the group content of the list's paint attribute values to legacy
/// form, so a legacy product owns everything its attributes reach.
/// Converts the group content the list's attribute values reach to legacy
/// form, so a legacy product owns everything its attributes hold: the paint
/// cells inside each appearance, and the merged-layers snapshot.
pub fn map_paint_attrs_to_legacy<T>(list: &mut List<T>) {
for key in [ATTR_FILL, ATTR_STROKE, crate::markers::ATTR_EDITOR_MERGED_LAYERS] {
let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(key) else { continue };
if let Some(appearances) = list.iter_attribute_values_mut::<Appearance>(ATTR_APPEARANCE) {
for appearance in appearances {
let Some(cells) = appearance.0.iter_attribute_values_mut::<Graphic>(ATTR_PAINT) else { continue };
for cell in cells {
*cell = map_groups_to_legacy(cell);
}
}
}
if let Some(values) = list.iter_attribute_values_mut::<Option<List<Graphic>>>(ATTR_EDITOR_MERGED_LAYERS) {
for value in values.flatten() {
for element in value.iter_element_values_mut() {
*element = map_groups_to_legacy(element);
@@ -87,7 +95,6 @@ pub fn group_to_legacy_list(group: &core_types::record::Group) -> List<Graphic<'
for element in list.iter_element_values_mut() {
*element = map_groups_to_legacy(element);
}
push_lane_paint_into_interiors(&mut list);
return list;
}
None.or_else(|| run_to_legacy_list::<Vector>(item).map(|list| detable_items(list, Graphic::Vector)))
@@ -102,24 +109,25 @@ pub fn group_to_legacy_list(group: &core_types::record::Group) -> List<Graphic<'
#[cfg(test)]
mod run_tests {
use super::*;
use crate::appearance::Coverage;
use crate::graphic::test_support::{native_group_paint, unit_square_at};
use crate::markers::Fill;
use core_types::attribute::Attribute;
use crate::markers::Appearance as AppearanceMarker;
use core_types::record::{FieldWrite, RunBuilder, element_write_hashed};
use glam::DVec2;
#[test]
fn a_legacy_list_owns_its_paint_attr_content() {
fn a_legacy_list_owns_its_appearance_paint_content() {
let inner_vector = unit_square_at(DVec2::ZERO);
let source = core_types::arena::Arena::new(1 << 16).unwrap();
// SAFETY: the erased native list serves only while `source` is live; the
// deep glue under test replaces its borrows at the copy-out seam.
let paint = unsafe { core_types::record::erase_static(native_group_paint(&inner_vector, &source)) };
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint.clone()));
let vector = unit_square_at(DVec2::new(4., 4.));
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&source, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let legacy = run_to_legacy_list::<Vector>(&item).expect("the run lowers to a legacy vector list");
let expected = map_groups_to_legacy(paint.element(0).unwrap());
@@ -127,9 +135,10 @@ mod run_tests {
drop(paint);
drop(source);
let served = legacy.attribute::<Option<List<Graphic>>>(Fill::NAME, 0).expect("the fill attribute rides the list");
let served = served.as_ref().expect("the fill is present");
assert_eq!(served.element(0).unwrap(), &expected);
let served = legacy.attribute::<Appearance>(ATTR_APPEARANCE, 0).expect("the appearance rides the list");
let cell = served.paint_at(0).expect("the fill coverage keeps its paint");
let Graphic::Graphic(cell_rows) = cell else { panic!("the paint cell keeps the list form") };
assert_eq!(cell_rows.element(0).unwrap(), &expected);
}
#[test]
@@ -10,13 +10,12 @@ pub(crate) use glue::{list_contains_groups, map_attribute_groups_to_owned, map_a
pub use glue::{map_groups_to_owned, map_groups_to_persistent, map_groups_to_resident};
pub(crate) use legacy::run_to_legacy_list;
pub use legacy::{group_to_legacy_graphic, group_to_legacy_list, map_groups_to_legacy, map_paint_attrs_to_legacy, run_to_list};
pub use paint::{
LanePaint, PaintColumns, PaintOverlay, PaintOverlayColumn, PaintReach, bake_paint_transforms, has_paint, is_paint_present, paint_graphics, set_paint_attribute, set_paint_attribute_at,
vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
};
pub use paint::{PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, paint_cell_rows, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path};
pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows};
use walk::{group_all_clipped, group_bounding_box, group_is_fully_transparent, group_is_opaque, group_render_complexity};
use crate::appearance::Appearance;
use crate::markers::ATTR_APPEARANCE;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
@@ -195,6 +194,7 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
let current_opacity: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY, 1.);
let current_fill: f64 = current_graphic_item.attribute_cloned_or(ATTR_OPACITY_FILL, 1.);
let lane_layer_path: Option<Vec<NodeId>> = current_graphic_item.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH).cloned();
let parent_appearance = current_graphic_item.attribute::<Appearance>(ATTR_APPEARANCE).and_then(Appearance::declared).cloned();
let (element, attributes) = current_graphic_item.into_parts();
match element {
@@ -222,6 +222,14 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
*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, lane_layer_path.as_deref());
}
@@ -418,9 +426,9 @@ impl<'e> Graphic<'e> {
}
}
pub fn can_reduce_to_clip_path(&self) -> bool {
pub fn can_reduce_to_clip_path(&self, inherited_appearance: Option<&Appearance>) -> bool {
match self {
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector)),
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&core_types::lane::Single(vector), inherited_appearance),
_ => false,
}
}
@@ -443,9 +451,8 @@ impl<'e> Graphic<'e> {
match self {
Graphic::None => true,
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked
// vector is invisible on its own.
Graphic::Vector(vector) => vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()),
// A bare vector leaf carries no paint or stroke of its own, so it is invisible on its own
Graphic::Vector(_) => true,
Graphic::Color(color) => color.a() == 0.,
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
@@ -674,6 +681,33 @@ mod tests {
let flattened: List<Vector> = group.into_flattened_list();
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_items() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
// Declaring an appearance on item 0 forces the attribute, padding item 1 with the empty appearance
let mut inner = List::new();
inner.push(Item::new_from_element(vector_graphic()));
inner.push(Item::new_from_element(vector_graphic()));
inner.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
let mut outer = List::new_from_element(Graphic::Graphic(inner));
outer.set_attribute(ATTR_APPEARANCE, 0, single(Color::WHITE));
let flattened: List<Vector> = outer.into_flattened_list();
let color_of = |index: usize| {
let appearance = flattened.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Graphic::Color(color) = appearance.paint_at(0)? else { return None };
Some(*color)
};
assert_eq!(color_of(0), Some(Color::BLACK), "a declared item should keep its own appearance");
assert_eq!(color_of(1), Some(Color::WHITE), "a padded item should inherit the parent appearance");
}
}
#[cfg(test)]
@@ -1,9 +1,10 @@
//! The paint column level: fill and stroke read as lane columns and threaded down to the elements they reach.
//! The appearance cascade level: the declared appearance read as a lane column and threaded down to the elements it reaches.
use super::{Graphic, IntoGraphicList};
use crate::markers::{ATTR_FILL, ATTR_STROKE, Fill, Stroke};
use super::Graphic;
use crate::appearance::Appearance;
use crate::markers::Appearance as AppearanceMarker;
use core_types::ATTR_TRANSFORM;
use core_types::attribute::{Attribute, Opacity};
use core_types::attribute::Opacity;
use core_types::lane::{LaneColumn, LaneSource};
use core_types::list::{ItemAttributeValues, List};
use glam::DAffine2;
@@ -15,216 +16,87 @@ pub fn is_paint_present(graphic_list: &List<Graphic>) -> bool {
graphic_list.element(0).is_some_and(|graphic| !graphic.is_empty())
}
/// Look up the paint graphics stored under the marker `A`, in the canonical `List<Graphic>` form.
pub fn paint_graphics<'a, A, S>(source: &'a S, index: usize) -> Option<&'a List<Graphic<'static>>>
where
S: LaneSource,
A: Attribute<Value<'a> = Option<&'a List<Graphic<'static>>>>,
{
source
.attr::<A>(index)
// 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 under the marker `A`,
/// checked by borrowing without cloning the renderable list.
pub fn has_paint<'a, A, S>(source: &'a S, index: usize) -> bool
where
S: LaneSource,
A: Attribute<Value<'a> = Option<&'a List<Graphic<'static>>>>,
{
paint_graphics::<A, S>(source, index).is_some()
}
/// Whether one lane of a vector source draws as a plain clip path: fully
/// opaque, fill absent or opaque, stroke invisible or fully transparent.
pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize) -> bool {
let Some(element) = source.element(index) else { return false };
pub fn vector_lane_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, index: usize, inherited_appearance: Option<&Appearance>) -> bool {
if source.element(index).is_none() {
return false;
}
let opacity: f64 = source.attr::<Opacity>(index);
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
let fill_opaque_or_absent = resolved
.fill_paint
.and_then(paint_cell_rows)
.is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let stroke_invisible_or_transparent = resolved.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| resolved
.stroke_paint
.and_then(paint_cell_rows)
.is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
}
/// Whether every lane of a vector source draws as a plain clip path.
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S) -> bool {
(0..source.lane_count()).all(|index| vector_lane_can_reduce_to_clip_path(source, index))
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>) -> bool {
(0..source.lane_count()).all(|index| vector_lane_can_reduce_to_clip_path(source, index, inherited_appearance))
}
/// The paint a lane carries for its interiors, in the reference form
/// [`PaintOverlay`] threads down.
#[derive(Clone, Copy, Default)]
pub struct LanePaint<'a> {
pub fill: Option<&'a List<Graphic<'static>>>,
pub stroke: Option<&'a List<Graphic<'static>>>,
}
impl<'a> LanePaint<'a> {
pub const NONE: Self = Self { fill: None, stroke: None };
pub fn is_present(&self) -> bool {
self.fill.is_some() || self.stroke.is_some()
}
}
/// A source's fill and stroke columns, resolved once for per-lane reads.
/// A source's declared appearance column, resolved once for per-lane reads.
pub struct PaintColumns<'a, S: LaneSource + 'a> {
fill: S::Column<'a, Fill>,
stroke: S::Column<'a, Stroke>,
appearance: S::Column<'a, AppearanceMarker>,
}
impl<'a, S: LaneSource> PaintColumns<'a, S> {
pub fn new(source: &'a S) -> Self {
Self {
fill: source.column::<Fill>(),
stroke: source.column::<Stroke>(),
appearance: source.column::<AppearanceMarker>(),
}
}
/// The lane's present, non-blank paint.
pub fn read(&self, lane: usize) -> LanePaint<'a> {
let present = |value: Option<Option<&'a List<Graphic<'static>>>>| value.flatten().filter(|list| is_paint_present(list));
LanePaint {
fill: present(self.fill.try_get(lane)),
stroke: present(self.stroke.try_get(lane)),
}
/// The lane's own declared appearance; an absent or empty cell is undeclared.
pub fn read_appearance(&self, lane: usize) -> Option<&'a Appearance> {
self.appearance.try_get(lane).flatten().and_then(Appearance::declared)
}
}
/// How far a lane's paint reaches into the element beneath it, mirroring the
/// legacy conversion's paint push: vector interiors directly and vector
/// children of a nested graphic list, one level deep.
/// The appearance cascade threading down the graphic levels: a lane's own
/// declared appearance wins wholesale, an undeclared lane inherits the
/// nearest ancestor's, at any depth. Only a fresh entry (a pattern's own
/// render, or any standalone render root) starts without an inherited one.
#[derive(Clone, Copy)]
pub struct PaintReach<'a> {
pub paint: LanePaint<'a>,
hops: u8,
/// The cascade's resolved appearance: the nearest declared one at or above this lane.
pub appearance: Option<&'a Appearance>,
}
impl<'a> PaintReach<'a> {
pub const NONE: Self = Self { paint: LanePaint::NONE, hops: 0 };
pub const NONE: Self = Self { appearance: None };
/// The lane's effective reach: an inherited paint stays authoritative
/// (lane paint below a push's origin is inert in the legacy model), an
/// absent one reads the lane's own paint.
/// The lane's effective reach: its own declared appearance wins over the inherited one.
pub fn for_lane<S: LaneSource>(self, columns: &PaintColumns<'a, S>, index: usize) -> Self {
match self.paint.is_present() {
true => self,
false => Self { paint: columns.read(index), hops: 2 },
}
}
pub fn applies(&self) -> bool {
self.hops > 0 && self.paint.is_present()
}
/// The reach one graphic nesting level further down.
pub fn nested(self) -> Self {
Self {
paint: self.paint,
hops: self.hops.saturating_sub(1),
}
}
/// The reach entering a group's own graphic run: a spent or absent reach
/// resets so the group's own lane paint applies at its own boundary.
pub fn into_group_graphics(self) -> Self {
match self.applies() {
true => self.nested(),
false => Self::NONE,
appearance: Appearance::cascade(columns.read_appearance(index), self.appearance),
}
}
}
/// A source with a lane's paint forced over its fill and stroke columns,
/// reaching the interiors the legacy conversion's paint push reached.
pub struct PaintOverlay<'a, S> {
inner: &'a S,
paint: LanePaint<'a>,
}
impl<'a, S> PaintOverlay<'a, S> {
pub fn new(inner: &'a S, paint: LanePaint<'a>) -> Self {
Self { inner, paint }
}
}
pub struct PaintOverlayColumn<'a, S: LaneSource + 'a, A: Attribute> {
inner: S::Column<'a, A>,
forced: Option<A::Value<'a>>,
}
impl<'a, S: LaneSource, A: Attribute> LaneColumn<'a, A> for PaintOverlayColumn<'a, S, A> {
fn try_get(&self, lane: usize) -> Option<A::Value<'a>> {
match self.forced {
Some(forced) => Some(forced),
None => self.inner.try_get(lane),
}
}
}
/// The forced value for the marker `A`: the lane paint where `A` is this
/// crate's fill or stroke marker, absent otherwise.
fn forced_paint<'a, A: Attribute>(paint: LanePaint<'a>) -> Option<A::Value<'a>> {
let slot = match A::NAME {
name if name == Fill::NAME => paint.fill,
name if name == Stroke::NAME => paint.stroke,
/// The paint a coverage row's cell holds, in the canonical `List<Graphic>` form the paint
/// renderers consume: this crate's writers carry the list as one graphic cell, and a bare
/// cell of any other form is treated as paint that draws nothing.
pub fn paint_cell_rows<'a>(cell: &'a Graphic<'static>) -> Option<&'a List<Graphic<'static>>> {
match cell {
Graphic::Graphic(list) => Some(list).filter(|list| is_paint_present(list)),
_ => None,
}?;
assert_eq!(
std::any::TypeId::of::<A::Value<'static>>(),
std::any::TypeId::of::<Option<&'static List<Graphic<'static>>>>(),
"attribute `{}` is declared at another value type than this crate's paint form",
A::NAME
);
assert_eq!(
size_of::<A::Value<'a>>(),
size_of::<Option<&'a List<Graphic<'a>>>>(),
"the paint value form must span the marker's value"
);
// SAFETY: the census admits one value type per attribute name, so a `fill` or `stroke` marker carries this crate's `Option<&List<Graphic>>` at the asserted size.
Some(unsafe { std::mem::transmute_copy::<Option<&'a List<Graphic>>, A::Value<'a>>(&Some(slot)) })
}
impl<'a, S: LaneSource> LaneSource for PaintOverlay<'a, S> {
type Element = S::Element;
type Column<'b, A: Attribute>
= PaintOverlayColumn<'b, S, A>
where
Self: 'b;
fn lane_count(&self) -> usize {
self.inner.lane_count()
}
fn element(&self, lane: usize) -> Option<&S::Element> {
self.inner.element(lane)
}
fn column<A: Attribute>(&self) -> PaintOverlayColumn<'_, S, A> {
PaintOverlayColumn {
inner: self.inner.column::<A>(),
forced: forced_paint::<A>(self.paint),
}
}
}
/// Stores a paint attribute in the paint marker's owned form, the only representation paint readers accept.
pub fn set_paint_attribute(attributes: &mut ItemAttributeValues, key: &str, paint: impl IntoGraphicList) {
attributes.insert(key, Some(paint.into_graphic_list()));
}
/// Stores a paint attribute at a list index in the paint marker's owned 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, Some(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.
/// Bake the provided transform into the per-item transforms of the paint
/// graphics inside the item's appearance coverage cells.
pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DAffine2) {
fn bake_graphic_paint_transform(graphics: &mut List<Graphic>, transform: DAffine2) {
for item_transform in graphics.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
@@ -237,9 +109,13 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
}
}
for paint_key in [ATTR_FILL, ATTR_STROKE] {
if let Some(Some(graphics)) = attributes.get_mut::<Option<List<Graphic>>>(paint_key) {
bake_graphic_paint_transform(graphics, transform);
if let Some(appearance) = attributes.get_mut::<Appearance>(crate::markers::ATTR_APPEARANCE)
&& let Some(cells) = appearance.0.iter_attribute_values_mut::<Graphic>(crate::markers::ATTR_PAINT)
{
for cell in cells {
if let Graphic::Graphic(list) = cell {
bake_graphic_paint_transform(list, transform);
}
}
}
}
@@ -254,22 +130,44 @@ mod run_tests {
use glam::DVec2;
#[test]
fn a_run_serves_the_parked_paint_reference() {
let paint = List::new_from_element(Graphic::Color(Color::BLACK));
fn a_run_serves_the_parked_appearance_reference() {
use crate::appearance::Coverage;
use core_types::lane::LaneSource;
let appearance = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::BLACK));
let vector = unit_square_at(DVec2::ZERO);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vector.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let run = RunView::<Vector>::new(&item).expect("the run holds vector elements");
assert_eq!(run.attr::<Fill>(0), Some(&paint));
assert_eq!(paint_graphics::<Fill, _>(&run, 0), Some(&paint));
assert_eq!(paint_graphics::<Stroke, _>(&run, 0), None);
assert_eq!(run.attr::<AppearanceMarker>(0), Some(&appearance));
let legacy = run_to_legacy_list::<Vector>(&item).expect("the run lowers to a legacy vector list");
assert_eq!(paint_graphics::<Fill, _>(&legacy, 0), paint_graphics::<Fill, _>(&run, 0));
assert_eq!(legacy.attr::<AppearanceMarker>(0), Some(&appearance));
}
#[test]
fn reach_cascades_the_appearance_with_own_wins_arbitration() {
use crate::appearance::Coverage;
use crate::markers::ATTR_APPEARANCE;
let own = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::BLACK));
let inherited = Appearance::new_single(Coverage::new_fill(), Graphic::Color(Color::WHITE));
// Lane 0 declares its own appearance, lane 1 is padded with the empty (undeclared) one
let mut list: List<Graphic<'static>> = List::new_from_element(Graphic::Vector(Vector::default()));
list.push(core_types::list::Item::new_from_element(Graphic::Vector(Vector::default())));
list.set_attribute(ATTR_APPEARANCE, 0, own.clone());
let columns = PaintColumns::new(&list);
let ancestor = PaintReach { appearance: Some(&inherited) };
assert_eq!(ancestor.for_lane(&columns, 0).appearance, Some(&own), "a declared lane wins over the inherited appearance");
assert_eq!(ancestor.for_lane(&columns, 1).appearance, Some(&inherited), "a padded lane inherits");
assert_eq!(PaintReach::NONE.for_lane(&columns, 1).appearance, None, "no ancestor leaves an undeclared lane bare");
}
}
@@ -1,8 +1,9 @@
//! The native-content walk: vector rows reached through a graphic's own storage, with no legacy conversion.
use super::Graphic;
use super::paint::{LanePaint, PaintColumns, PaintReach, is_paint_present, paint_graphics, set_paint_attribute_at};
use crate::markers::{ATTR_FILL, ATTR_STROKE, Fill};
use super::paint::{PaintColumns, PaintReach};
use crate::appearance::Appearance;
use crate::markers::ATTR_APPEARANCE;
use core_types::attribute::{Attribute, ClippingMask, EditorLayerPath, Opacity, OpacityFill, Transform};
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::lane::LaneSource;
@@ -251,7 +252,7 @@ pub struct VectorRow<'w> {
source: RowSourceRef<'w>,
scale: FlattenScale,
layer_path: Option<&'w [NodeId]>,
paint: LanePaint<'w>,
appearance: Option<&'w Appearance>,
/// The lane of the walk's OWN top level whose subtree produced this row.
/// A leaf at any depth reports the top-level row it descends from, which is
/// the lane whose columns a consumer carries onto it.
@@ -283,16 +284,14 @@ impl VectorRow<'_> {
}
}
/// Whether the built row will carry fill paint: the reaching lane paint,
/// else the row's own.
/// Whether the built row will carry fill paint: a painted fill coverage on
/// the row's resolved appearance.
pub fn has_fill(&self) -> bool {
if self.paint.fill.is_some() {
return true;
}
match &self.source {
RowSourceRef::Lane(level, index) => paint_graphics::<Fill, _>(level, *index).is_some(),
RowSourceRef::Run(run, _, index) => paint_graphics::<Fill, _>(*run, *index).is_some(),
}
let own = match &self.source {
RowSourceRef::Lane(level, index) => level.attr::<crate::markers::Appearance>(*index),
RowSourceRef::Run(run, _, index) => LaneSource::attr::<crate::markers::Appearance>(*run, *index),
};
Appearance::cascade(own, self.appearance).is_some_and(|appearance| appearance.has_painted_cover(crate::appearance::Cover::Fill))
}
/// Builds the row at the end of `out`, applying the reach paint and the
@@ -309,11 +308,6 @@ impl VectorRow<'_> {
out.push(Item::from_parts(vector, run_lane_attributes(item, *lane)));
}
}
for (key, slot) in [(ATTR_FILL, self.paint.fill), (ATTR_STROKE, self.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, index, key, paint.clone());
}
}
if self.scale.has_transform || out.attribute::<DAffine2>(ATTR_TRANSFORM, index).is_some() {
let row_transform: DAffine2 = out.attribute_cloned_or_default(ATTR_TRANSFORM, index);
out.set_attribute(ATTR_TRANSFORM, index, self.scale.transform * row_transform);
@@ -329,6 +323,12 @@ impl VectorRow<'_> {
if let Some(layer_path) = self.layer_path {
out.set_attribute(ATTR_EDITOR_LAYER_PATH, index, layer_path.to_vec());
}
// The cascade's resolved appearance lands on a row whose own is undeclared, since a declared row wins wholesale
if let Some(appearance) = self.appearance
&& out.attribute::<Appearance>(ATTR_APPEARANCE, index).and_then(Appearance::declared).is_none()
{
out.set_attribute(ATTR_APPEARANCE, index, appearance.clone());
}
}
}
@@ -338,7 +338,7 @@ fn walk_rows_of_run(
item: &core_types::record::GroupItem,
scale: FlattenScale,
layer_path: Option<&[NodeId]>,
paint: LanePaint<'_>,
appearance: Option<&Appearance>,
top_lane: Option<usize>,
visit: &mut dyn FnMut(VectorRow<'_>) -> RowStep,
) -> RowStep {
@@ -350,7 +350,7 @@ fn walk_rows_of_run(
source: RowSourceRef::Run(&run, item, lane),
scale,
layer_path,
paint,
appearance,
top_lane: top_lane.unwrap_or(lane),
}) {
return RowStep::Stop;
@@ -360,9 +360,9 @@ fn walk_rows_of_run(
}
/// Walks a graphic level into its flattened vector rows, matching the legacy
/// push-then-flatten lowering: lane paint threads with [`PaintReach`],
/// ancestor transform, opacity and fill opacity compose down, the containing
/// level's parent layer path overwrites its rows, and non-vector content is
/// flatten lowering: the appearance cascades with [`PaintReach`], ancestor
/// transform, opacity and fill opacity compose down, the containing level's
/// parent layer path overwrites its rows, and non-vector content is
/// discarded. A de-tabled leaf's row is its lane, attributes included.
pub fn walk_vector_rows(level: GraphicLevel<'_>, visit: &mut dyn FnMut(VectorRow<'_>) -> RowStep) {
walk_vector_rows_impl(level, FlattenScale::ROOT, None, PaintReach::NONE, None, visit);
@@ -382,21 +382,13 @@ fn walk_vector_rows_impl<'a>(
if let GraphicLevel::Run(item) = level {
// A vector-typed run is already its rows.
if item.typed_lanes::<Vector>().is_some() {
let paint = match inherited.applies() {
true => inherited.paint,
false => LanePaint::NONE,
};
return walk_rows_of_run(item, scale, parent_layer_path, paint, top_lane, visit);
return walk_rows_of_run(item, scale, parent_layer_path, inherited.appearance, top_lane, visit);
}
}
let columns = PaintColumns::new(&level);
for index in 0..level.lane_count() {
let Some(element) = level.element(index) else { continue };
let reach = inherited.for_lane(&columns, index);
let row_paint = match reach.applies() {
true => reach.paint,
false => LanePaint::NONE,
};
// At the top level this lane IS the row every leaf under it reports.
let row_top = top_lane.unwrap_or(index);
let step = match element {
@@ -404,27 +396,27 @@ fn walk_vector_rows_impl<'a>(
source: RowSourceRef::Lane(level, index),
scale,
layer_path: parent_layer_path,
paint: row_paint,
appearance: reach.appearance,
top_lane: row_top,
}),
Graphic::Graphic(children) => walk_vector_rows_impl(
GraphicLevel::Legacy(children),
scale.composed(&level, index),
level.try_attr::<EditorLayerPath>(index),
reach.nested(),
reach,
Some(row_top),
visit,
),
Graphic::Group(group) => {
let item = &group.content;
if item.typed_lanes::<Vector>().is_some() {
walk_rows_of_run(item, scale.composed(&level, index), level.try_attr::<EditorLayerPath>(index), row_paint, Some(row_top), visit)
walk_rows_of_run(item, scale.composed(&level, index), level.try_attr::<EditorLayerPath>(index), reach.appearance, Some(row_top), visit)
} else if item.typed_lanes::<Graphic>().is_some() {
walk_vector_rows_impl(
GraphicLevel::Run(item),
scale.composed(&level, index),
level.try_attr::<EditorLayerPath>(index),
reach.into_group_graphics(),
reach,
Some(row_top),
visit,
)
@@ -452,26 +444,6 @@ pub fn flatten_vector_rows(level: GraphicLevel<'_>) -> List<Vector> {
out
}
/// The transitional paint placement: a lane-level fill or stroke paint
/// attribute moves onto the vector interiors the legacy paint readers
/// inspect, reaching as far as the pre-flip broadcast did.
pub(in crate::graphic) fn push_lane_paint_into_interiors(list: &mut List<Graphic>) {
for index in 0..list.len() {
for key in [ATTR_FILL, ATTR_STROKE] {
let stored = list.attribute::<Option<List<Graphic>>>(key, index).and_then(|optional| optional.as_ref());
let Some(paint) = stored.filter(|paint| is_paint_present(paint)).cloned() else {
continue;
};
let Some(Graphic::Graphic(children)) = list.element_mut(index) else { continue };
for child in 0..children.len() {
if matches!(children.element(child), Some(Graphic::Vector(_))) {
set_paint_attribute_at(children, child, key, paint.clone());
}
}
}
}
}
/// The count [`map_groups_to_legacy`] would expose through [`Graphic::as_vector`],
/// read from the run's lanes instead of materializing the legacy list. Mirrors
/// [`group_to_legacy_graphic`]'s typed-run path, where `Vector` is tried first.
@@ -544,6 +516,10 @@ mod run_tests {
#[test]
fn the_vector_row_walk_matches_the_legacy_flatten() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
let inner_vector = unit_square_at(DVec2::ZERO);
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[], 1).unwrap();
@@ -555,7 +531,7 @@ mod run_tests {
painted.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ONE))));
painted.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(1., 0.)));
painted.set_attribute(core_types::ATTR_TRANSFORM, 1, DAffine2::from_translation(DVec2::new(0., 1.)));
set_paint_attribute_at(&mut painted, 1, ATTR_FILL, List::new_from_element(Graphic::Color(Color::WHITE)));
painted.set_attribute(ATTR_APPEARANCE, 1, single(Color::WHITE));
let mut nested = List::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(2., 2.))));
nested.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_scale(DVec2::splat(2.)));
@@ -568,12 +544,12 @@ mod run_tests {
top.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(6., 0.)))));
top.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(5., 5.)));
top.set_attribute(core_types::ATTR_EDITOR_LAYER_PATH, 0, vec![core_types::uuid::NodeId(7)]);
set_paint_attribute_at(&mut top, 0, ATTR_FILL, List::new_from_element(Graphic::Color(Color::BLACK)));
top.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
top.set_attribute(core_types::ATTR_OPACITY, 1, 0.5);
top.set_attribute(core_types::ATTR_TRANSFORM, 2, DAffine2::from_scale(DVec2::splat(3.)));
top.set_attribute(core_types::ATTR_TRANSFORM, 4, DAffine2::from_translation(DVec2::new(0., 7.)));
top.set_attribute(core_types::ATTR_EDITOR_LAYER_PATH, 4, vec![core_types::uuid::NodeId(9)]);
set_paint_attribute_at(&mut top, 4, ATTR_FILL, List::new_from_element(Graphic::Color(Color::WHITE)));
top.set_attribute(ATTR_APPEARANCE, 4, single(Color::WHITE));
let legacy = {
let mut list = List::new();
@@ -581,7 +557,6 @@ mod run_tests {
let (element, attributes) = item.into_parts();
list.push(Item::from_parts(map_groups_to_legacy(&element), attributes));
}
push_lane_paint_into_interiors(&mut list);
list.into_flattened_list::<Vector>()
};
let native = flatten_vector_rows(GraphicLevel::Legacy(&top));
@@ -603,9 +578,9 @@ mod run_tests {
"layer path, row {row}"
);
assert_eq!(
native.attribute::<Option<List<Graphic>>>(ATTR_FILL, row),
legacy.attribute::<Option<List<Graphic>>>(ATTR_FILL, row),
"fill, row {row}"
native.attribute::<Appearance>(ATTR_APPEARANCE, row),
legacy.attribute::<Appearance>(ATTR_APPEARANCE, row),
"appearance, row {row}"
);
}
assert_eq!(native, legacy);
@@ -634,4 +609,33 @@ mod run_tests {
assert_eq!(run.thumbnail_bounding_box(outer, include_stroke), legacy.thumbnail_bounding_box(outer, include_stroke));
}
}
#[test]
fn the_walk_cascades_appearance_like_the_legacy_flatten() {
use crate::appearance::Coverage;
let single = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Color(color));
let mut inner = List::new();
inner.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ZERO))));
inner.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::ONE))));
inner.set_attribute(ATTR_APPEARANCE, 0, single(Color::BLACK));
let mut top = List::new_from_element(Graphic::Graphic(inner));
top.set_attribute(ATTR_APPEARANCE, 0, single(Color::WHITE));
let walked = flatten_vector_rows(GraphicLevel::Legacy(&top));
let legacy: List<Vector> = top.clone().into_flattened_list();
let color_of = |list: &List<Vector>, index: usize| {
let appearance = list.attribute::<Appearance>(ATTR_APPEARANCE, index)?;
let Graphic::Color(color) = appearance.paint_at(0)? else { return None };
Some(*color)
};
for list in [&walked, &legacy] {
assert_eq!(color_of(list, 0), Some(Color::BLACK), "a declared row keeps its own appearance");
assert_eq!(color_of(list, 1), Some(Color::WHITE), "an undeclared row inherits the level's appearance");
}
}
}
+15 -10
View File
@@ -1,3 +1,4 @@
pub mod appearance;
pub mod artboard;
pub mod boundary;
pub mod graphic;
@@ -9,9 +10,10 @@ pub use raster_types;
pub use vector_types;
// 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 graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector};
pub use markers::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE};
pub use markers::{ATTR_APPEARANCE, ATTR_EDITOR_MERGED_LAYERS, ATTR_PAINT};
pub mod migrations {
use crate::Vector;
@@ -99,6 +101,7 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>,
}
@@ -106,6 +109,7 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct VectorData {
#[allow(dead_code)]
pub style: PathStyle,
pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain,
@@ -138,7 +142,6 @@ pub mod migrations {
Ok(match VectorFormat::deserialize(deserializer)? {
VectorFormat::OldVectorData(old) => Some(Vector {
stroke: old.style.stroke,
colinear_manipulators: old.colinear_manipulators,
point_domain: old.point_domain,
segment_domain: old.segment_domain,
@@ -190,9 +193,12 @@ pub mod migrations {
use vector_types::vector::style::Stroke;
#[test]
fn preserves_stroke_from_old_vector_data_style() {
fn recovers_geometry_from_old_vector_data_style() {
use core_types::ops::FromAnchorPosition;
let old_vector = legacy::VectorData {
style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) },
point_domain: Vector::from_anchor_position(glam::DVec2::new(3., 4.)).point_domain,
..Default::default()
};
@@ -207,20 +213,19 @@ pub mod migrations {
.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.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)], "the geometry survives alongside the discarded style");
}
#[test]
fn preserves_stroke_from_current_vector_data() {
let vector = Vector {
stroke: Some(Stroke::new(12.)),
..Default::default()
};
fn recovers_geometry_from_current_vector_data() {
use core_types::ops::FromAnchorPosition;
let vector = Vector::from_anchor_position(glam::DVec2::new(3., 4.));
let value = serde_json::to_value(&vector).unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)]);
}
}
}
@@ -1,30 +1,104 @@
//! Attribute markers whose value types live in this crate, with their name
//! constants for the string-keyed legacy readers and writers.
//!
//! The list-valued markers may carry native [`Graphic::Group`] content: the
//! registered deep field glue owns it across persistence seams, and legacy
//! products convert it through [`crate::graphic::map_paint_attrs_to_legacy`].
//! The markers may carry native [`Graphic::Group`] content: the registered
//! deep field glue owns it across persistence seams, and legacy products
//! convert it through [`crate::graphic::map_paint_attrs_to_legacy`].
use crate::Graphic;
use core_types::attribute::Attribute;
use core_types::list::List;
core_types::attribute! {
/// Vector graphics object's filled area paint, a graphic list in the canonical paint form.
/// An absent value means no fill.
pub Fill("fill"): Option<&List<Graphic<'static>>>;
/// Vector graphics object's stroke paint, a graphic list in the canonical paint form.
/// An absent value means no stroke paint.
pub Stroke("stroke"): Option<&List<Graphic<'static>>>;
/// Snapshot of the upstream content that fed into a destructive merge (Boolean Operation,
/// Rasterize, etc.), so the editor can still surface click targets for the original child
/// layers after their content has been collapsed.
pub EditorMergedLayers("editor:merged_layers"): Option<&List<Graphic<'static>>>;
}
pub const ATTR_FILL: &str = Fill::NAME;
pub const ATTR_STROKE: &str = Stroke::NAME;
/// The item's ordered list of paint passes. An absent or empty value is the undeclared
/// state that inherits the nearest ancestor's appearance through the cascade, so both
/// read as `None`. The stored form is the bare [`crate::appearance::Appearance`], the
/// shape the appearance writers use, which the `attribute!` macro's optional-reference
/// arm cannot express.
pub struct Appearance;
// SAFETY: `read_erased` produces the bare owned appearance `from_stored` reads, `REPARK`
// re-parks that same form, and the empty appearance collapses to the `None` default at
// every read seam.
unsafe impl Attribute for Appearance {
const NAME: &'static str = "appearance";
type Value<'e> = Option<&'e crate::appearance::Appearance>;
fn from_stored<'a>(stored: &'a dyn std::any::Any) -> Option<Self::Value<'a>> {
stored.downcast_ref::<crate::appearance::Appearance>().map(crate::appearance::Appearance::declared)
}
unsafe fn read_erased(ptr: *const u8) -> Box<dyn core_types::list::AnyAttributeValue> {
Box::new(unsafe { ptr.cast::<Option<&crate::appearance::Appearance>>().read() }.cloned().unwrap_or_default())
}
const REPARK: Option<core_types::list::ReparkFn> = {
unsafe fn repark(value: &dyn core_types::list::AnyAttributeValue, dst: *mut u8, arena: &core_types::arena::Arena) -> Option<()> {
let owned: &crate::appearance::Appearance = value.as_any().downcast_ref().expect("an appearance attribute replays its bare owned clone");
let parked = match owned.is_empty() {
true => None,
false => {
let (parked, _) = arena.alloc(owned.clone())?;
Some(parked)
}
};
// SAFETY: the slot is a live field of this marker's value type.
unsafe { dst.cast::<Option<&crate::appearance::Appearance>>().write(parked) };
Some(())
}
Some(repark)
};
}
core_types::attribute!(@register Appearance);
/// One coverage row's paint, a bare graphic riding the coverage list as a column. Absent
/// or empty paint draws nothing, so both read as `None`; the stored form is the bare
/// [`Graphic`], the shape [`crate::appearance`]'s row writers use.
pub struct Paint;
// SAFETY: as for `Appearance`, at the bare `Graphic` stored form.
unsafe impl Attribute for Paint {
const NAME: &'static str = "paint";
type Value<'e> = Option<&'e Graphic<'static>>;
fn from_stored<'a>(stored: &'a dyn std::any::Any) -> Option<Self::Value<'a>> {
stored.downcast_ref::<Graphic<'static>>().map(|paint| (!paint.is_empty()).then_some(paint))
}
unsafe fn read_erased(ptr: *const u8) -> Box<dyn core_types::list::AnyAttributeValue> {
Box::new(unsafe { ptr.cast::<Option<&Graphic<'static>>>().read() }.cloned().unwrap_or_default())
}
const REPARK: Option<core_types::list::ReparkFn> = {
unsafe fn repark(value: &dyn core_types::list::AnyAttributeValue, dst: *mut u8, arena: &core_types::arena::Arena) -> Option<()> {
let owned: &Graphic<'static> = value.as_any().downcast_ref().expect("a paint attribute replays its bare owned clone");
let parked = match owned.is_empty() {
true => None,
false => {
let (parked, _) = arena.alloc(owned.clone())?;
Some(parked)
}
};
// SAFETY: the slot is a live field of this marker's value type.
unsafe { dst.cast::<Option<&Graphic<'static>>>().write(parked) };
Some(())
}
Some(repark)
};
}
core_types::attribute!(@register Paint);
pub const ATTR_EDITOR_MERGED_LAYERS: &str = EditorMergedLayers::NAME;
pub const ATTR_APPEARANCE: &str = Appearance::NAME;
pub const ATTR_PAINT: &str = Paint::NAME;
#[cfg(test)]
mod tests {
@@ -34,25 +108,13 @@ mod tests {
#[test]
fn the_census_carries_this_crates_names() {
for name in ["fill", "stroke", "editor:merged_layers"] {
assert_eq!(info(name).unwrap().value_type, TypeId::of::<Option<&'static List<Graphic>>>());
}
assert_eq!(info("editor:merged_layers").unwrap().value_type, TypeId::of::<Option<&'static List<Graphic>>>());
assert_eq!(info("appearance").unwrap().value_type, TypeId::of::<Option<&'static crate::appearance::Appearance>>());
assert_eq!(info("paint").unwrap().value_type, TypeId::of::<Option<&'static Graphic>>());
}
#[test]
fn an_absent_paint_defaults_to_none() {
assert_eq!(<Fill as Attribute>::default(), None);
}
#[test]
fn a_paint_marker_reads_back_what_the_paint_writer_stored() {
use core_types::lane::LaneSource;
let paint = List::new_from_element(Graphic::default());
let mut list = List::new_from_element(Graphic::default());
crate::graphic::set_paint_attribute_at(&mut list, 0, ATTR_FILL, paint.clone());
assert_eq!(list.attr::<Fill>(0), Some(&paint));
assert_eq!(list.attr::<Stroke>(0), None);
assert_eq!(<Paint as Attribute>::default(), None);
}
}
@@ -185,3 +185,60 @@ impl Display for BlendMode {
}
}
}
/// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller.
pub fn apply_blend_mode(foreground: crate::color::Color, background: crate::color::Color, blend_mode: BlendMode) -> crate::color::Color {
use crate::color::Color;
match blend_mode {
// Normal group
BlendMode::Normal => background.blend_rgb(foreground, Color::blend_normal),
// Darken group
BlendMode::Darken => background.blend_rgb(foreground, Color::blend_darken),
BlendMode::Multiply => background.blend_rgb(foreground, Color::blend_multiply),
BlendMode::ColorBurn => background.blend_rgb(foreground, Color::blend_color_burn),
BlendMode::LinearBurn => background.blend_rgb(foreground, Color::blend_linear_burn),
BlendMode::DarkerColor => background.blend_darker_color(foreground),
// Lighten group
BlendMode::Lighten => background.blend_rgb(foreground, Color::blend_lighten),
BlendMode::Screen => background.blend_rgb(foreground, Color::blend_screen),
BlendMode::ColorDodge => background.blend_rgb(foreground, Color::blend_color_dodge),
BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge),
BlendMode::LighterColor => background.blend_lighter_color(foreground),
// Contrast group
BlendMode::Overlay => background.blend_rgb(foreground, Color::blend_overlay),
BlendMode::SoftLight => background.blend_rgb(foreground, Color::blend_softlight),
BlendMode::HardLight => background.blend_rgb(foreground, Color::blend_hardlight),
BlendMode::VividLight => background.blend_rgb(foreground, Color::blend_vivid_light),
BlendMode::LinearLight => background.blend_rgb(foreground, Color::blend_linear_light),
BlendMode::PinLight => background.blend_rgb(foreground, Color::blend_pin_light),
BlendMode::HardMix => background.blend_rgb(foreground, Color::blend_hard_mix),
// Inversion group
BlendMode::Difference => background.blend_rgb(foreground, Color::blend_difference),
BlendMode::Exclusion => background.blend_rgb(foreground, Color::blend_exclusion),
BlendMode::Subtract => background.blend_rgb(foreground, Color::blend_subtract),
BlendMode::Divide => background.blend_rgb(foreground, Color::blend_divide),
// Component group
BlendMode::Hue => background.blend_hue(foreground),
BlendMode::Saturation => background.blend_saturation(foreground),
BlendMode::Color => background.blend_color(foreground),
BlendMode::Luminosity => background.blend_luminosity(foreground),
// The alpha-only utility modes mix no color, so the foreground passes through for the caller to composite
BlendMode::Erase | BlendMode::Restore | BlendMode::MultiplyAlpha => foreground,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
#[test]
fn overlay_is_hard_light_with_swapped_operands() {
let a = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.);
let b = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.);
let overlay = apply_blend_mode(a, b, BlendMode::Overlay);
let swapped_hard_light = apply_blend_mode(b, a, BlendMode::HardLight);
assert_eq!(overlay, swapped_hard_light);
}
}
@@ -750,6 +750,11 @@ impl Color {
}
}
/// Per-channel "Overlay" blend: hard light with its operands swapped.
pub fn blend_overlay(c_b: f32, c_s: f32) -> f32 {
Self::blend_hardlight(c_s, c_b)
}
/// Per-channel "Hard Light" blend.
pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
@@ -1,4 +1,6 @@
use crate::renderer::{ClearGuardPlacement, RenderParams, format_transform_matrix, gradient_placement, spread_adjusted_samples, transform_is_invertible};
use crate::renderer::{
ClearGuardPlacement, RenderParams, composite_paint_colors, format_transform_matrix, gradient_placement, paint_faded_samples, paint_lane_opacity, spread_adjusted_samples, transform_is_invertible,
};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::Color;
use core_types::attribute::Transform;
@@ -52,13 +54,13 @@ pub trait RenderExt {
) -> Self::Output;
}
/// The color paint attribute over any color lane source.
pub fn render_color_paint<S: core_types::lane::LaneSource<Element = Color>>(source: &S, target: PaintTarget) -> String {
let Some(color) = source.element(0) else {
/// The color paint attribute for a composited paint color.
pub fn render_color_paint(color: Option<Color>, target: PaintTarget) -> String {
let Some(color) = color else {
return format!(r#" {}="none""#, target.paint_attr());
};
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(*color).to_rgb_hex());
let mut result = format!(r##" {}="#{}""##, target.paint_attr(), SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" {}="{}""#, target.opacity_attr(), (color.a() * 1000.).round() / 1000.);
}
@@ -76,10 +78,10 @@ impl RenderExt for List<Color> {
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
render_color_paint(self, target)
render_color_paint(composite_paint_colors(self, |color| Some(*color), render_params.for_mask), target)
}
}
@@ -94,16 +96,16 @@ impl RenderExt for List<Gradient> {
element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
render_gradient_paint(self, svg_defs, item_transform, element_transform)
render_gradient_paint(self, svg_defs, item_transform, element_transform, render_params.for_mask)
}
}
/// Adds the gradient def through mutating `svg_defs`, returning the gradient
/// ID, over any gradient lane source.
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 {
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2, for_mask: bool) -> u64 {
let mut stop = String::new();
{
@@ -113,6 +115,7 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>
let settings = GradientSettings::from_lane_attributes(source, 0);
let (samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let samples = paint_faded_samples(samples, paint_lane_opacity(source, 0, for_mask));
for (position, color, original_midpoint) in samples {
stop.push_str("<stop");
@@ -209,7 +212,7 @@ impl RenderExt for Stroke {
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_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);
let paint_order = render_params.stroke_below.then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = String::new();
@@ -258,9 +261,13 @@ impl RenderExt for List<Graphic<'_>> {
let paint_attr = target.paint_attr();
match fill_graphic {
Some(Graphic::Color(color)) => render_color_paint(&core_types::lane::LeafLane::new(self, 0, color), target),
Some(Graphic::Color(_)) => {
// The whole color stack collapses to the one composited color the fast path emits
let composited = composite_paint_colors(self, |graphic| if let Graphic::Color(color) = graphic { Some(*color) } else { None }, render_params.for_mask);
render_color_paint(composited, target)
}
Some(Graphic::Gradient(gradient)) => {
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform);
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform, render_params.for_mask);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::None) => format!(r#" {paint_attr}="none""#),
+319 -137
View File
@@ -23,17 +23,16 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::graphic::{
PaintColumns, PaintOverlay, PaintReach, has_paint, is_paint_present, paint_graphics, set_paint_attribute, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path,
};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::appearance::{Appearance, Coverage};
use graphic_types::graphic::{PaintColumns, PaintReach, is_paint_present, paint_cell_rows, vector_can_reduce_to_clip_path, vector_lane_can_reduce_to_clip_path};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm, GradientSettings};
use graphic_types::vector_types::markers::GradientForm as GradientFormAttr;
use graphic_types::vector_types::subpath::Subpath;
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::{ATTR_FILL, Artboard, Graphic, Vector};
use graphic_types::vector_types::vector::style::{RenderMode, StrokeAlign, StrokeCap, StrokeJoin};
use graphic_types::{Artboard, Graphic, Vector};
use kurbo::{Affine, BezPath, Cap, Join, Shape, StrokeOpts};
use num_traits::Zero;
use skrifa::instance::{LocationRef, NormalizedCoord, Size};
@@ -209,6 +208,98 @@ pub struct RenderContext {
pub resource_overrides: Vec<(peniko::ImageBrush, Texture)>,
}
/// The single black-fill appearance a mask clone paints with, at full alpha so the mask fully covers the interior.
fn black_fill_appearance() -> Appearance {
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(List::new_from_element(Graphic::Color(Color::BLACK))))
}
/// The alpha multiplier a paint row's opacity attributes apply when it serves as a paint.
/// Fill opacity fades a paint just as opacity does, but a masker drops it so it cannot reach the content clipped to it.
pub(crate) fn paint_row_opacity<T>(list: &List<T>, index: usize, for_mask: bool) -> f32 {
let opacity_fill = if for_mask {
1.
} else {
list.attribute_cloned_or::<f64>(core_types::ATTR_OPACITY_FILL, index, 1.)
};
(list.attribute_cloned_or::<f64>(core_types::ATTR_OPACITY, index, 1.) * opacity_fill) as f32
}
/// [`paint_row_opacity`] over a lane source, for the gradient paths that hold one.
pub(crate) fn paint_lane_opacity<S: LaneSource>(source: &S, index: usize, for_mask: bool) -> f32 {
let opacity_fill: f64 = if for_mask { 1. } else { source.attr::<OpacityFill>(index) };
(source.attr::<Opacity>(index) * opacity_fill) as f32
}
/// Fades a gradient's renderer samples by the paint row's own opacity, applied after
/// interpolation so the ramp's interpolation space is the one the stops were sampled in.
/// Transparent `Clear` guards are unaffected, their alpha already being zero.
pub(crate) fn paint_faded_samples(samples: GradientSamples, paint_opacity: f32) -> GradientSamples {
if paint_opacity >= 1. {
return samples;
}
samples
.into_iter()
.map(|(position, color, midpoint)| (position, color.with_alpha(color.a() * paint_opacity), midpoint))
.collect()
}
/// Composites one paint color over the stack beneath it, mixing by the blend mode and then source-over in straight alpha.
fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Color {
let (over_alpha, under_alpha) = (over.a(), under.a());
// These modes only move the backdrop's alpha, leaving its color alone
match blend_mode {
BlendMode::Erase => return under.with_alpha((under_alpha - over_alpha).clamp(0., 1.)),
BlendMode::Restore => return under.with_alpha((under_alpha + over_alpha).clamp(0., 1.)),
BlendMode::MultiplyAlpha => return under.with_alpha(under_alpha * over_alpha),
_ => {}
}
let result_alpha = over_alpha + under_alpha * (1. - over_alpha);
if result_alpha <= 0. {
return Color::TRANSPARENT;
}
// The blend formulas read their backdrop premultiplied
let premultiplied_under = Color::from_rgbaf32_unchecked(under.r() * under_alpha, under.g() * under_alpha, under.b() * under_alpha, under_alpha);
let mixed = core_types::blending::apply_blend_mode(over, premultiplied_under, blend_mode);
// The mode only mixes where the backdrop has coverage, so its alpha interpolates each source channel from the raw color to the mixed color
let source_channel = |over_channel: f32, mixed_channel: f32| over_channel * (1. - under_alpha) + mixed_channel * under_alpha;
let channel =
|mixed_channel: f32, over_channel: f32, under_channel: f32| (source_channel(over_channel, mixed_channel) * over_alpha + under_channel * under_alpha * (1. - over_alpha)) / result_alpha;
Color::from_rgbaf32_unchecked(
channel(mixed.r(), over.r(), under.r()),
channel(mixed.g(), over.g(), under.g()),
channel(mixed.b(), over.b(), under.b()),
result_alpha,
)
}
/// Flattens a color paint into the single color the fast path emits, stacking the rows in paint order.
/// `element_color` reads a row's color, `None` skipping rows of another element type.
pub(crate) fn composite_paint_colors<T>(list: &List<T>, element_color: impl Fn(&T) -> Option<Color>, for_mask: bool) -> Option<Color> {
let mut composited = None;
for index in 0..list.len() {
let Some(color) = list.element(index).and_then(&element_color) else { continue };
let faded = color.with_alpha(color.a() * paint_row_opacity(list, index, for_mask));
composited = Some(match composited {
// The lowest paint has nothing beneath it, so its blend mode has nothing to act on
None => faded,
Some(under) => composite_paint_over(faded, under, list.attribute_cloned_or::<BlendMode>(core_types::ATTR_BLEND_MODE, index, BlendMode::default())),
});
}
composited
}
#[derive(Default, Clone, Copy, Hash, graphene_hash::CacheHash)]
pub enum RenderOutputType {
#[default]
@@ -232,7 +323,7 @@ pub struct RenderParams {
/// Are we generating a mask for alignment? Used to prevent unnecessary transforms in masks
pub alignment_parent_transform: Option<DAffine2>,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub stroke_below: bool,
/// Are we rendering for a pattern content
pub inside_pattern: bool,
pub artboard_background: Option<Color>,
@@ -504,7 +595,7 @@ fn peniko_extend(gradient_spread: GradientSpread) -> peniko::Extend {
}
}
fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2) -> Option<(peniko::Brush, DAffine2)> {
fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list: &S, multiplied_transform: &DAffine2, for_mask: bool) -> Option<(peniko::Brush, DAffine2)> {
let stops = gradient_list.element(0)?;
let gradient_form: GradientForm = gradient_list.attr::<GradientFormAttr>(0);
@@ -512,6 +603,7 @@ fn create_peniko_gradient_brush<S: LaneSource<Element = Gradient>>(gradient_list
let settings = GradientSettings::from_lane_attributes(gradient_list, 0);
let (samples, span) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::VelloRampTexels);
let samples = paint_faded_samples(samples, paint_lane_opacity(gradient_list, 0, for_mask));
let peniko_stops = peniko_color_stops(&samples);
@@ -560,12 +652,9 @@ pub struct RenderMetadata {
pub text_frames: HashMap<NodeId, DAffine2>,
pub clip_targets: HashSet<NodeId>,
pub vector_data: HashMap<NodeId, Arc<Vector>>,
/// Per-layer `ATTR_FILL` row attribute, exposed so message handlers can read it.
/// Per-layer resolved appearance snapshot, exposed so message handlers can read the paint.
#[cfg_attr(feature = "serde", serde(skip))]
pub fill_attributes: HashMap<NodeId, Arc<List<Graphic<'static>>>>,
/// Per-layer `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<'static>>>>,
pub appearance_attributes: HashMap<NodeId, Arc<Appearance>>,
pub backgrounds: Vec<Background>,
}
@@ -589,8 +678,7 @@ impl RenderMetadata {
text_frames,
clip_targets,
vector_data,
fill_attributes,
stroke_attributes,
appearance_attributes,
backgrounds,
} = self;
upstream_footprints.extend(other.upstream_footprints.iter());
@@ -601,8 +689,7 @@ impl RenderMetadata {
text_frames.extend(other.text_frames.iter());
clip_targets.extend(other.clip_targets.iter());
vector_data.extend(other.vector_data.iter().map(|(id, data)| (*id, data.clone())));
fill_attributes.extend(other.fill_attributes.iter().map(|(id, data)| (*id, data.clone())));
stroke_attributes.extend(other.stroke_attributes.iter().map(|(id, data)| (*id, data.clone())));
appearance_attributes.extend(other.appearance_attributes.iter().map(|(id, data)| (*id, data.clone())));
// TODO: Find a better non O(n^2) way to merge backgrounds
for background in &other.backgrounds {
@@ -651,7 +738,7 @@ impl Render for Graphic<'_> {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_svg(render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), None, render, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_svg(&Single(raster), render, render_params),
Graphic::RasterGPU(_) => (),
Graphic::Color(color) => render_color_svg(&Single(color), render, render_params),
@@ -665,7 +752,7 @@ impl Render for Graphic<'_> {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), None, scene, transform, context, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_vello(&Single(raster), scene, transform, render_params),
Graphic::RasterGPU(raster) => render_raster_gpu_vello(&Single(raster), scene, transform, context, render_params),
Graphic::Color(color) => render_color_vello(&Single(color), scene, render_params),
@@ -707,8 +794,8 @@ impl Render for Graphic<'_> {
fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_svg(&PaintOverlay::new(&Single(vector), reach.paint), render, render_params),
Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach.nested(), render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), reach.appearance, render, render_params),
Graphic::Graphic(inner) => render_graphic_svg_with(inner, reach, render, render_params),
Graphic::Group(group) => render_group_svg(group, reach, render, render_params),
_ => element.render_svg(render, render_params),
}
@@ -716,8 +803,8 @@ fn render_element_svg<'a>(element: &'a Graphic, reach: PaintReach<'a>, render: &
fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
match element {
Graphic::Vector(vector) if reach.applies() => render_vector_vello(&PaintOverlay::new(&Single(vector), reach.paint), scene, transform, context, render_params),
Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach.nested(), scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), reach.appearance, scene, transform, context, render_params),
Graphic::Graphic(inner) => render_graphic_vello_with(inner, reach, scene, transform, context, render_params),
Graphic::Group(group) => render_group_vello(group, reach, scene, transform, context, render_params),
_ => element.render_to_vello(scene, transform, context, render_params),
}
@@ -725,13 +812,12 @@ fn render_element_vello<'a>(element: &'a Graphic, reach: PaintReach<'a>, scene:
fn element_can_reduce_to_clip_path<'a>(element: &'a Graphic, reach: PaintReach<'a>) -> bool {
match element {
Graphic::Vector(vector) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&Single(vector), reach.paint)),
Graphic::Vector(vector) => vector_can_reduce_to_clip_path(&Single(vector), reach.appearance),
Graphic::Group(group) => match RunView::<Vector>::new(&group.content) {
Some(run) if reach.applies() => vector_can_reduce_to_clip_path(&PaintOverlay::new(&run, reach.paint)),
Some(run) => vector_can_reduce_to_clip_path(&run),
Some(run) => vector_can_reduce_to_clip_path(&run, reach.appearance),
None => false,
},
_ => element.can_reduce_to_clip_path(),
_ => element.can_reduce_to_clip_path(reach.appearance),
}
}
@@ -762,9 +848,8 @@ fn collect_element_metadata<'a>(
match element {
Graphic::None => {}
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach.nested(), metadata, footprint, element_id),
Graphic::Vector(vector) if reach.applies() => collect_vector_metadata(&PaintOverlay::new(&Single(vector), reach.paint), metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), metadata, footprint, element_id),
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach, metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), reach.appearance, metadata, footprint, element_id),
Graphic::RasterCPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::RasterGPU(raster) => collect_raster_metadata(&Single(raster), metadata, footprint, element_id),
Graphic::Color(_) => {}
@@ -806,9 +891,8 @@ fn collect_group_row_metadata(group: &Group, metadata: &mut RenderMetadata, elem
fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, click_targets: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach.nested(), click_targets),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach, click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), reach.appearance, click_targets),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(click_targets),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => click_targets.extend(gradient_control_targets(&Single(gradient), |transform| transform, true)),
@@ -820,9 +904,8 @@ fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReac
fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, outlines: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach.nested(), outlines),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach, outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), reach.appearance, outlines),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) => add_raster_upstream_click_targets(outlines),
Graphic::Color(_) => {}
Graphic::Gradient(gradient) => outlines.extend(gradient_control_targets(&Single(gradient), |transform| transform, false)),
@@ -836,12 +919,9 @@ fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintRe
fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut SvgRender, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
render_graphic_svg_with(&run, reach.into_group_graphics(), render, render_params)
render_graphic_svg_with(&run, reach, render, render_params)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => render_vector_svg(&PaintOverlay::new(&run, reach.paint), render, render_params),
false => render_vector_svg(&run, render, render_params),
}
render_vector_svg(&run, reach.appearance, render, render_params)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
render_raster_cpu_svg(&run, render, render_params)
} else if item.typed_lanes::<Raster<GPU>>().is_some() {
@@ -857,12 +937,9 @@ fn render_group_svg<'a>(group: &'a Group, reach: PaintReach<'a>, render: &mut Sv
fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
render_graphic_vello_with(&run, reach.into_group_graphics(), scene, transform, context, render_params)
render_graphic_vello_with(&run, reach, scene, transform, context, render_params)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => render_vector_vello(&PaintOverlay::new(&run, reach.paint), scene, transform, context, render_params),
false => render_vector_vello(&run, scene, transform, context, render_params),
}
render_vector_vello(&run, reach.appearance, scene, transform, context, render_params)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
render_raster_cpu_vello(&run, scene, transform, render_params)
} else if let Some(run) = RunView::<Raster<GPU>>::new(item) {
@@ -882,12 +959,9 @@ fn render_group_vello<'a>(group: &'a Group, reach: PaintReach<'a>, scene: &mut S
fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata: &mut RenderMetadata, footprint: Footprint, element_id: Option<NodeId>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
collect_graphic_metadata_with(&run, reach.into_group_graphics(), metadata, footprint, element_id)
collect_graphic_metadata_with(&run, reach, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => collect_vector_metadata(&PaintOverlay::new(&run, reach.paint), metadata, footprint, element_id),
false => collect_vector_metadata(&run, metadata, footprint, element_id),
}
collect_vector_metadata(&run, reach.appearance, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Raster<CPU>>::new(item) {
collect_raster_metadata(&run, metadata, footprint, element_id)
} else if let Some(run) = RunView::<Raster<GPU>>::new(item) {
@@ -903,12 +977,9 @@ fn collect_group_metadata<'a>(group: &'a Group, reach: PaintReach<'a>, metadata:
fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>, click_targets: &mut Vec<ClickTarget>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
add_graphic_upstream_click_targets_with(&run, reach.into_group_graphics(), click_targets)
add_graphic_upstream_click_targets_with(&run, reach, click_targets)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => add_vector_upstream_click_targets(&PaintOverlay::new(&run, reach.paint), click_targets),
false => add_vector_upstream_click_targets(&run, click_targets),
}
add_vector_upstream_click_targets(&run, reach.appearance, click_targets)
} else if item.typed_lanes::<Raster<CPU>>().is_some() || item.typed_lanes::<Raster<GPU>>().is_some() {
add_raster_upstream_click_targets(click_targets)
} else if let Some(run) = RunView::<Gradient>::new(item) {
@@ -921,12 +992,9 @@ fn add_group_upstream_click_targets<'a>(group: &'a Group, reach: PaintReach<'a>,
fn add_group_upstream_outline_targets<'a>(group: &'a Group, reach: PaintReach<'a>, outlines: &mut Vec<ClickTarget>) {
let item = &group.content;
if let Some(run) = RunView::<Graphic>::new(item) {
add_graphic_upstream_outline_targets_with(&run, reach.into_group_graphics(), outlines)
add_graphic_upstream_outline_targets_with(&run, reach, outlines)
} else if let Some(run) = RunView::<Vector>::new(item) {
match reach.applies() {
true => add_vector_upstream_outline_targets(&PaintOverlay::new(&run, reach.paint), outlines),
false => add_vector_upstream_outline_targets(&run, outlines),
}
add_vector_upstream_outline_targets(&run, reach.appearance, outlines)
} else if item.typed_lanes::<Raster<CPU>>().is_some() || item.typed_lanes::<Raster<GPU>>().is_some() {
add_raster_upstream_click_targets(outlines)
} else if let Some(run) = RunView::<Gradient>::new(item) {
@@ -1397,14 +1465,19 @@ impl Render for List<Graphic<'_>> {
}
/// Emits one lane of a vector source as SVG, with no wrapping group of its own.
fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: usize, vector: &Vector, render: &mut SvgRender, render_params: &RenderParams) {
fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: usize, vector: &Vector, inherited_appearance: Option<&Appearance>, render: &mut SvgRender, render_params: &RenderParams) {
let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let element_stroke = resolved.stroke.as_ref();
// 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 = element_stroke.filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform_is_invertible(*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);
@@ -1412,7 +1485,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = vector.bounding_box().unwrap_or_default();
let transformed_bounds = vector.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY).unwrap_or(layer_bounds);
let stroke_layer_bounds = vector.stroke_inclusive_bounding_box_with_transform(DAffine2::IDENTITY, element_stroke).unwrap_or(layer_bounds);
let bounds_matrix = DAffine2::from_scale_angle_translation(layer_bounds[1] - layer_bounds[0], 0., layer_bounds[0]);
let stroke_bounds_matrix = DAffine2::from_scale_angle_translation(stroke_layer_bounds[1] - stroke_layer_bounds[0], 0., stroke_layer_bounds[0]);
@@ -1424,26 +1497,27 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
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 element_stroke.map(|x| x.align) == Some(StrokeAlign::Inside) {
MaskType::Clip
} else {
MaskType::Mask
};
let fill_graphic_list = paint_graphics::<Fill, _>(source, index);
let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows);
let fill_graphic = fill_graphic_list.and_then(|l| l.element(0));
let stroke_graphic_list = paint_graphics::<Stroke, _>(source, index);
let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows);
let stroke_graphic = stroke_graphic_list.and_then(|l| l.element(0));
let path_is_closed = vector.stroke_bezier_paths().all(|path| path.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())
&& element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& 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 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);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let override_paint_order = can_draw_aligned_stroke && can_use_paint_order;
let use_face_fill = vector.use_face_fill();
@@ -1463,13 +1537,12 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
let push_id = needs_separate_alignment_fill.then_some({
let id = format!("alignment-{}", generate_uuid());
let mut cloned_vector = vector.clone();
cloned_vector.stroke = None;
let cloned_vector = vector.clone();
// The mask must draw at full alpha so the SVG `<mask>`/`<clipPath>` fully zeroes the path interior.
// The wrapping SVG group (above) handles the user-set opacity.
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));
mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance());
let vector_item = List::new_from_item(mask_item);
(id, mask_type, vector_item)
@@ -1504,7 +1577,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
if let Some((ref id, mask_type, ref vector_item)) = push_id {
let mut svg = SvgRender::new();
vector_item.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let stroke = vector.stroke.as_ref().unwrap();
let stroke = element_stroke.expect("push_id is only set when an aligned stroke can draw");
// `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 inflation = stroke.max_aabb_inflation(true) * largest_scale;
@@ -1529,11 +1602,9 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
let mut render_params = render_params.clone();
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
.stroke
.as_ref()
let stroke_shape_attribute = element_stroke
.map(|stroke| {
if stroke_graphic_list.is_some_and(is_paint_present) {
stroke.render(defs, item_transform, element_transform, applied_stroke_transform, bounds_matrix, &render_params, PaintTarget::Stroke)
@@ -1544,7 +1615,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
.unwrap_or_default();
// 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 = element_stroke.is_some_and(|stroke| stroke.has_renderable_stroke()) && stroke_graphic.is_some_and(|g| !g.is_fully_transparent());
let stroke_attribute = if stroke_visible {
stroke_graphic_list
.map(|list| {
@@ -1606,7 +1677,7 @@ fn render_vector_item_svg<S: LaneSource<Element = Vector>>(source: &S, index: us
}
}
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut SvgRender, render_params: &RenderParams) {
fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, render: &mut SvgRender, render_params: &RenderParams) {
let mut clip_mask_state: Option<(u64, MaskType)> = None;
for index in 0..source.lane_count() {
@@ -1617,11 +1688,15 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
let mut masked_by = None;
if next_clips && clip_mask_state.is_none() {
let mask_type = if vector_lane_can_reduce_to_clip_path(source, index) { MaskType::Clip } else { MaskType::Mask };
let mask_type = if vector_lane_can_reduce_to_clip_path(source, index, inherited_appearance) {
MaskType::Clip
} else {
MaskType::Mask
};
let uuid = generate_uuid();
let mut masker_svg = SvgRender::new();
render_vector_item_svg(source, index, vector, &mut masker_svg, &render_params.for_clipper());
render_vector_item_svg(source, index, vector, inherited_appearance, &mut masker_svg, &render_params.for_clipper());
render.svg_defs.push_str(&masker_svg.svg_defs);
mask_type.write_to_defs(&mut render.svg_defs, uuid, masker_svg.svg.to_svg_string());
@@ -1639,9 +1714,9 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
Some((attribute, selector)) => render.parent_tag(
"g",
|attributes| attributes.push(attribute, selector),
|render| render_vector_item_svg(source, index, vector, render, render_params),
|render| render_vector_item_svg(source, index, vector, inherited_appearance, render, render_params),
),
None => render_vector_item_svg(source, index, vector, render, render_params),
None => render_vector_item_svg(source, index, vector, inherited_appearance, render, render_params),
}
}
}
@@ -1649,24 +1724,31 @@ fn render_vector_svg<S: LaneSource<Element = Vector>>(source: &S, render: &mut S
/// Emits one lane of a vector source to Vello. `clip_masker` names the lane whose
/// paint masks this one; its layers are pushed inside this item's blend layer so the
/// mask cuts this item's own paint rather than the composited result.
#[expect(clippy::too_many_arguments, reason = "one lane's full render context: source and index, the cascade, the vello sink, and the masking lane")]
fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
source: &S,
index: usize,
inherited_appearance: Option<&Appearance>,
scene: &mut Scene,
parent_transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
clip_masker: Option<usize>,
) {
use graphic_types::vector_types::vector;
let Some(element) = source.element(index) else { return };
let item_transform: DAffine2 = source.attr::<Transform>(index);
let blend_mode_attr: BlendMode = source.attr::<BlendModeAttr>(index);
let opacity_attr: f64 = source.attr::<Opacity>(index);
let opacity_fill_attr: f64 = source.attr::<OpacityFill>(index);
let multiplied_transform = parent_transform * item_transform;
let has_real_stroke = element.stroke.as_ref().filter(|stroke| stroke.weight() > 0.);
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
let fill_graphic_list = resolved.fill_paint.and_then(paint_cell_rows);
let stroke_graphic_list = resolved.stroke_paint.and_then(paint_cell_rows);
let has_real_stroke = resolved.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));
let mut applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let mut element_transform = set_stroke_transform
@@ -1690,9 +1772,6 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
}
let fill_graphic_list = paint_graphics::<Fill, _>(source, index);
let stroke_graphic_list = paint_graphics::<Stroke, _>(source, index);
// If we're using opacity or a blend mode, we need to push a layer
let blend_mode = match render_params.render_mode {
RenderMode::Outline => peniko::Mix::Normal,
@@ -1703,7 +1782,7 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
// 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
// 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 = resolved.stroke.as_ref();
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 = !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());
@@ -1736,7 +1815,7 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
&& let Some((layer_affine, layer_rect)) = layer_geometry
{
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., layer_affine, &layer_rect);
render_vector_item_vello(source, masker_index, scene, parent_transform, context, &render_params.for_clipper(), None);
render_vector_item_vello(source, masker_index, inherited_appearance, scene, parent_transform, context, &render_params.for_clipper(), None);
scene.push_layer(
peniko::Fill::NonZero,
peniko::BlendMode::new(peniko::Mix::Normal, peniko::Compose::SrcIn),
@@ -1748,7 +1827,8 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
let use_layer = can_draw_aligned_stroke;
let wants_stroke_below = stroke.is_some_and(|s| s.paint_order == vector::style::PaintOrder::StrokeBelow);
// The paint order rides the coverage list's row order
let wants_stroke_below = resolved.stroke_below;
let do_fill_path = |scene: &mut Scene, context: &mut RenderContext, path: &kurbo::BezPath, fill_rule: peniko::Fill| {
let Some(fill_graphic) = fill_graphic_list else { return };
@@ -1758,11 +1838,13 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
match paint {
Graphic::None => continue,
Graphic::Color(color) => {
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
// The row's own opacity fades the pass, matching the composited SVG fast path
let color = color.with_alpha(color.a() * paint_row_opacity(fill_graphic, paint_index, render_params.for_mask));
let fill = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
}
Graphic::Gradient(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(fill_graphic, paint_index, gradient), &multiplied_transform, render_params.for_mask) else {
continue;
};
@@ -1839,12 +1921,15 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
match stroke_graphic {
Graphic::None => continue,
Graphic::Color(color) => {
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
// The row's own opacity fades the pass, matching the composited SVG fast path
let color = color.with_alpha(color.a() * paint_row_opacity(stroke_graphic_list, paint_index, render_params.for_mask));
let brush = peniko::Brush::Solid(SRGBA8::from(color).to_peniko_color());
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), &brush, None, &path);
}
Graphic::Gradient(gradient) => {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform) else {
let Some((brush, gradient_to_device)) = create_peniko_gradient_brush(&LeafLane::new(stroke_graphic_list, paint_index, gradient), &multiplied_transform, render_params.for_mask)
else {
continue;
};
let inverse_element_transform = if transform_is_invertible(element_transform) {
@@ -1876,13 +1961,12 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
_ => {
if use_layer {
let mut cloned_element = element.clone();
cloned_element.stroke = None;
let cloned_element = element.clone();
// The mask must draw at full alpha so `SrcOut` fully zeroes the path interior.
// The outer opacity/blend layer (above) handles the user-set opacity.
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));
mask_item.set_attribute(graphic_types::ATTR_APPEARANCE, black_fill_appearance());
let vector_list = List::new_from_item(mask_item);
let bounds = element.bounding_box_with_transform(multiplied_transform).unwrap_or(layer_bounds);
@@ -1930,7 +2014,7 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
Stroke,
}
let order = match stroke.is_some_and(|stroke| !stroke.paint_order.is_default()) {
let order = match stroke.is_some() && wants_stroke_below {
true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
};
@@ -1956,7 +2040,14 @@ fn render_vector_item_vello<S: LaneSource<Element = Vector>>(
}
}
fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
fn render_vector_vello<S: LaneSource<Element = Vector>>(
source: &S,
inherited_appearance: Option<&Appearance>,
scene: &mut Scene,
parent_transform: DAffine2,
context: &mut RenderContext,
render_params: &RenderParams,
) {
let mut clip_masker: Option<usize> = None;
for index in 0..source.lane_count() {
@@ -1964,7 +2055,16 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
let next_clips = index + 1 < source.lane_count() && source.attr::<ClippingMask>(index + 1);
let becomes_masker = next_clips && clip_masker.is_none();
render_vector_item_vello(source, index, scene, parent_transform, context, render_params, if becomes_masker { None } else { clip_masker });
render_vector_item_vello(
source,
index,
inherited_appearance,
scene,
parent_transform,
context,
render_params,
if becomes_masker { None } else { clip_masker },
);
if becomes_masker {
clip_masker = Some(index);
@@ -1974,7 +2074,13 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
}
}
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
fn collect_vector_metadata<S: LaneSource<Element = Vector>>(
source: &S,
inherited_appearance: Option<&Appearance>,
metadata: &mut RenderMetadata,
footprint: Footprint,
caller_element_id: Option<NodeId>,
) {
// 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.
let mut reference_transforms: HashMap<NodeId, DAffine2> = HashMap::new();
@@ -1988,6 +2094,10 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
let layer_path: &[NodeId] = source.attr::<EditorLayerPath>(index);
let layer = layer_path.last().copied();
// The lane's paint: its own declared appearance, or the nearest ancestor's through the cascade
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
if let Some(element_id) = caller_element_id.or(layer) {
let reference_transform = *reference_transforms.entry(element_id).or_insert(transform);
let reference_inverse = if transform_is_invertible(reference_transform) {
@@ -2002,12 +2112,12 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
let item_relative_transform = reference_inverse * transform;
let mut click_targets_unwrapped = Vec::new();
extend_targets_from_vector(&mut click_targets_unwrapped, source, index, click_target_vector, item_relative_transform);
extend_targets_from_vector(&mut click_targets_unwrapped, &resolved, click_target_vector, item_relative_transform);
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
let mut outlines_unwrapped = Vec::new();
extend_targets_from_vector(&mut outlines_unwrapped, source, index, element, item_relative_transform);
extend_targets_from_vector(&mut outlines_unwrapped, &resolved, element, item_relative_transform);
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.
@@ -2017,11 +2127,8 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
if let std::collections::hash_map::Entry::Vacant(e) = metadata.vector_data.entry(element_id) {
e.insert(Arc::new(element.clone()));
if let Some(fill_graphic) = source.attr::<Fill>(index).filter(|list| is_paint_present(list)) {
metadata.fill_attributes.insert(element_id, Arc::new(fill_graphic.clone()));
}
if let Some(stroke_graphic) = source.attr::<Stroke>(index).filter(|list| is_paint_present(list)) {
metadata.stroke_attributes.insert(element_id, Arc::new(stroke_graphic.clone()));
if let Some(appearance) = appearance {
metadata.appearance_attributes.insert(element_id, Arc::new(appearance.clone()));
}
}
@@ -2059,7 +2166,7 @@ fn collect_vector_metadata<S: LaneSource<Element = Vector>>(source: &S, metadata
}
}
fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, click_targets: &mut Vec<ClickTarget>) {
fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, click_targets: &mut Vec<ClickTarget>) {
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::<Transform>(index);
@@ -2067,39 +2174,45 @@ fn add_vector_upstream_click_targets<S: LaneSource<Element = Vector>>(source: &S
// Use click-target override geometry if the item provides one (e.g. 'Text' node's per-glyph bounding boxes)
let vector = source.attr::<EditorClickTarget>(index).unwrap_or(element);
extend_targets_from_vector(click_targets, source, index, vector, transform);
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
extend_targets_from_vector(click_targets, &resolved, vector, transform);
}
}
fn add_vector_upstream_outline_targets<S: LaneSource<Element = Vector>>(source: &S, outlines: &mut Vec<ClickTarget>) {
fn add_vector_upstream_outline_targets<S: LaneSource<Element = Vector>>(source: &S, inherited_appearance: Option<&Appearance>, outlines: &mut Vec<ClickTarget>) {
// Source geometry only, ignoring `editor:click_target`, so outlines reflect actual letterforms
for index in 0..source.lane_count() {
let Some(element) = source.element(index) else { continue };
let transform: DAffine2 = source.attr::<Transform>(index);
extend_targets_from_vector(outlines, source, index, element, transform);
let appearance = Appearance::cascade(source.attr::<AppearanceMarker>(index), inherited_appearance);
let resolved = appearance.map(Appearance::fill_and_stroke).unwrap_or_default();
extend_targets_from_vector(outlines, &resolved, element, transform);
}
}
impl Render for List<Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_vector_svg(self, render, render_params)
render_vector_svg(self, None, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_vector_vello(self, scene, parent_transform, context, render_params)
render_vector_vello(self, None, scene, parent_transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
collect_vector_metadata(self, metadata, footprint, caller_element_id)
collect_vector_metadata(self, None, metadata, footprint, caller_element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
add_vector_upstream_click_targets(self, click_targets)
add_vector_upstream_click_targets(self, None, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
add_vector_upstream_outline_targets(self, outlines)
add_vector_upstream_outline_targets(self, None, outlines)
}
fn new_ids_from_hash(&mut self, reference: Option<NodeId>) {
@@ -2111,15 +2224,15 @@ impl Render for List<Vector> {
/// 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.
fn extend_targets_from_vector<S: LaneSource<Element = Vector>>(targets: &mut Vec<ClickTarget>, source: &S, index: usize, geometry: &Vector, transform: DAffine2) {
let filled = has_paint::<Fill, _>(source, index);
fn extend_targets_from_vector(targets: &mut Vec<ClickTarget>, resolved: &graphic_types::appearance::FillAndStroke<'_>, geometry: &Vector, transform: DAffine2) {
let filled = resolved.fill_paint.and_then(paint_cell_rows).is_some();
let mut subpaths: Vec<Subpath<_>> = geometry.stroke_bezier_paths().collect();
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,
// 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 = resolved.stroke.as_ref().map_or(0., |stroke| {
if stroke.align.is_not_centered() && all_subpaths_closed {
stroke.weight * 2.
} else {
@@ -2560,6 +2673,7 @@ fn render_gradient_svg<S: LaneSource<Element = Gradient>>(source: &S, render: &m
}
let (samples, _) = spread_adjusted_samples(gradient, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let samples = paint_faded_samples(samples, paint_lane_opacity(source, index, render_params.for_mask));
let mut stop_string = String::new();
for (position, color, original_midpoint) in samples {
@@ -3169,23 +3283,23 @@ impl Render for RunView<'_, Graphic<'_>> {
impl Render for RunView<'_, Vector> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
render_vector_svg(self, render, render_params)
render_vector_svg(self, None, render, render_params)
}
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
render_vector_vello(self, scene, parent_transform, context, render_params)
render_vector_vello(self, None, scene, parent_transform, context, render_params)
}
fn collect_metadata(&self, metadata: &mut RenderMetadata, footprint: Footprint, caller_element_id: Option<NodeId>) {
collect_vector_metadata(self, metadata, footprint, caller_element_id)
collect_vector_metadata(self, None, metadata, footprint, caller_element_id)
}
fn add_upstream_click_targets(&self, click_targets: &mut Vec<ClickTarget>) {
add_vector_upstream_click_targets(self, click_targets)
add_vector_upstream_click_targets(self, None, click_targets)
}
fn add_upstream_outline_targets(&self, outlines: &mut Vec<ClickTarget>) {
add_vector_upstream_outline_targets(self, outlines)
add_vector_upstream_outline_targets(self, None, outlines)
}
}
@@ -3336,7 +3450,6 @@ impl SvgRenderAttrs<'_> {
mod group_walk_tests {
use super::*;
use core_types::record::{FieldWrite, RunBuilder, element_write_hashed};
use graphic_types::markers::Fill;
use graphic_types::vector_types::vector::PointId;
fn unit_square_at(corner: DVec2) -> Vector {
@@ -3347,6 +3460,11 @@ mod group_walk_tests {
List::new_from_element(Graphic::Color(Color::from_rgbaf32(0.8, 0.2, 0.33, 1.).unwrap()))
}
/// The appearance the fill node stamps, so test content mirrors node output.
fn fill_appearance(paint: &List<Graphic<'static>>) -> Appearance {
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint.clone()))
}
fn rendered_svg(render: impl FnOnce(&mut SvgRender)) -> (String, String) {
let mut svg_render = SvgRender::new();
render(&mut svg_render);
@@ -3359,9 +3477,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(3., 1.))];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 2).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 2).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
builder.push(vectors[1].clone()).unwrap();
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3378,9 +3497,10 @@ mod group_walk_tests {
let paint = color_paint();
let inner = Graphic::Vector(unit_square_at(DVec2::ZERO));
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Graphic>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Graphic>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(inner.clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3397,9 +3517,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO)];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 1).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 1).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3414,7 +3535,14 @@ mod group_walk_tests {
assert!(native.local_transforms.contains_key(&caller));
assert!(native.upstream_footprints.contains_key(&caller));
assert_eq!(native.vector_data.get(&caller).map(|vector| vector.as_ref()), Some(&vectors[0]));
assert!(native.fill_attributes.get(&caller).is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_)))));
assert!(
native
.appearance_attributes
.get(&caller)
.and_then(|appearance| appearance.first_paint_of(graphic_types::appearance::Cover::Fill))
.and_then(paint_cell_rows)
.is_some_and(|fill| matches!(fill.element(0), Some(Graphic::Color(_))))
);
}
#[test]
@@ -3422,9 +3550,10 @@ mod group_walk_tests {
let paint = color_paint();
let vectors = [unit_square_at(DVec2::ZERO), unit_square_at(DVec2::new(2., 2.))];
let arena = core_types::arena::Arena::new(1 << 16).unwrap();
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<Fill>(0)], 2).unwrap();
let appearance = fill_appearance(&paint);
let mut builder = RunBuilder::new(&arena, element_write_hashed::<Vector>(), &[FieldWrite::of::<AppearanceMarker>(0)], 2).unwrap();
let lane = builder.push(vectors[0].clone()).unwrap();
builder.attr::<Fill>(lane, Some(&paint));
builder.attr::<AppearanceMarker>(lane, Some(&appearance));
builder.push(vectors[1].clone()).unwrap();
let item = builder.finish();
let group = Group { row: None, content: item };
@@ -3441,6 +3570,59 @@ mod group_walk_tests {
Graphic::Group(group).add_upstream_outline_targets(&mut native_outlines);
assert_eq!(native_outlines, legacy);
}
#[test]
fn stacked_paint_colors_composite_in_straight_alpha() {
// A half-transparent red over an opaque blue lands halfway between the two
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 1., 1.)));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 0.5)));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.g() - 0.).abs() < 1e-5, "green was {}", composited.g());
assert!((composited.b() - 0.5).abs() < 1e-5, "blue was {}", composited.b());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
}
#[test]
fn stacked_paint_blending_interpolates_by_backdrop_coverage() {
// Multiply over half-covering black only half-multiplies the red
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(0., 0., 0., 0.5)));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 0.5).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
// Multiply over no backdrop at all leaves the source color untouched
let mut list = List::new();
list.push(Item::new_from_element(Color::TRANSPARENT));
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)).with_attribute(core_types::ATTR_BLEND_MODE, BlendMode::Multiply));
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.r() - 1.).abs() < 1e-5, "red was {}", composited.r());
assert!((composited.a() - 1.).abs() < 1e-5, "alpha was {}", composited.a());
}
#[test]
fn a_paint_rows_own_opacity_fades_its_color() {
let mut list = List::new();
list.push(Item::new_from_element(Color::from_rgbaf32_unchecked(1., 0., 0., 1.)));
list.set_attribute(core_types::ATTR_OPACITY, 0, 0.5_f64);
list.set_attribute(core_types::ATTR_OPACITY_FILL, 0, 0.5_f64);
let composited = composite_paint_colors(&list, |color| Some(*color), false).expect("a non-empty paint list composites to a color");
assert!((composited.a() - 0.25).abs() < 1e-5, "both opacities fade the paint, alpha was {}", composited.a());
// A masker drops the fill opacity so it cannot reach the content clipped to it
let masked = composite_paint_colors(&list, |color| Some(*color), true).expect("a non-empty paint list composites to a color");
assert!((masked.a() - 0.5).abs() < 1e-5, "the mask keeps only the plain opacity, alpha was {}", masked.a());
}
}
#[cfg(test)]
@@ -22,6 +22,20 @@ core_types::attribute! {
/// by clicking anywhere within their bounds, not just the filled letterform. An absent
/// value means the item's own geometry is the click target.
pub EditorClickTarget("editor:click_target"): Option<&crate::Vector>;
/// Stroke coverage's line thickness. Absent when equal to the stroke default.
pub Weight("weight"): f64;
/// Stroke coverage's dash lengths, alternating dash and gap. Absent when the pattern is solid.
pub DashPattern("dash_pattern"): Option<&Vec<f64>>;
/// Stroke coverage's phase offset into the dash pattern. Absent when equal to the stroke default.
pub DashOffset("dash_offset"): f64;
/// Stroke coverage's shape at open endpoints. Absent when equal to the stroke default.
pub Cap("cap"): crate::vector::style::StrokeCap;
/// Stroke coverage's corner curvature. Absent when equal to the stroke default.
pub Join("join"): crate::vector::style::StrokeJoin;
/// Stroke coverage's miter-to-bevel conversion threshold. Absent when equal to the stroke default.
pub JoinMiterLimit("join_miter_limit"): f64 = 4.;
/// Stroke coverage's alignment to the path centerline. Absent when equal to the stroke default.
pub Align("align"): crate::vector::style::StrokeAlign;
}
// The value types a name-generic attribute can name here, so a compile-time
@@ -41,6 +55,13 @@ pub const ATTR_GRADIENT_SPACE: &str = GradientSpace::NAME;
pub const ATTR_GRADIENT_HUE_DIRECTION: &str = GradientHueDirection::NAME;
pub const ATTR_GRADIENT_INTERPOLATION: &str = GradientInterpolation::NAME;
pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME;
pub const ATTR_WEIGHT: &str = Weight::NAME;
pub const ATTR_DASH_PATTERN: &str = DashPattern::NAME;
pub const ATTR_DASH_OFFSET: &str = DashOffset::NAME;
pub const ATTR_CAP: &str = Cap::NAME;
pub const ATTR_JOIN: &str = Join::NAME;
pub const ATTR_JOIN_MITER_LIMIT: &str = JoinMiterLimit::NAME;
pub const ATTR_ALIGN: &str = Align::NAME;
#[cfg(test)]
mod tests {
@@ -53,6 +74,24 @@ mod tests {
assert_eq!(info("gradient_form").unwrap().value_type, TypeId::of::<crate::gradient::GradientForm>());
assert_eq!(info("gradient_spread").unwrap().value_type, TypeId::of::<crate::gradient::GradientSpread>());
assert_eq!(info("editor:click_target").unwrap().value_type, TypeId::of::<Option<&'static crate::Vector>>());
assert_eq!(info("weight").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("dash_pattern").unwrap().value_type, TypeId::of::<Option<&'static Vec<f64>>>());
assert_eq!(info("dash_offset").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("cap").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeCap>());
assert_eq!(info("join").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeJoin>());
assert_eq!(info("join_miter_limit").unwrap().value_type, TypeId::of::<f64>());
assert_eq!(info("align").unwrap().value_type, TypeId::of::<crate::vector::style::StrokeAlign>());
}
#[test]
fn stroke_parameter_defaults_match_the_stroke_struct() {
let defaults = crate::vector::style::Stroke::default();
assert_eq!(<Weight as Attribute>::default(), defaults.weight);
assert_eq!(<DashOffset as Attribute>::default(), defaults.dash_offset);
assert_eq!(<JoinMiterLimit as Attribute>::default(), defaults.join_miter_limit);
assert_eq!(<Cap as Attribute>::default(), defaults.cap);
assert_eq!(<Join as Attribute>::default(), defaults.join);
assert_eq!(<Align as Attribute>::default(), defaults.align);
}
#[test]
@@ -235,8 +235,6 @@ pub struct Stroke {
pub align: StrokeAlign,
#[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))]
pub transform: DAffine2,
#[cfg_attr(feature = "serde", serde(default))]
pub paint_order: PaintOrder,
}
impl Stroke {
@@ -250,7 +248,6 @@ impl Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -287,7 +284,6 @@ impl Stroke {
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
trs * skew
},
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
}
}
@@ -402,7 +398,6 @@ impl Default for Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::default(),
}
}
}
@@ -8,7 +8,6 @@ use crate::vector::vector_modification::VectorExt;
use core::borrow::Borrow;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
use std::collections::HashMap;
@@ -17,8 +16,6 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq, dyn_any::DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Vector {
pub stroke: Option<Stroke>,
/// A list of all manipulator groups (referenced in `subpaths`) that have colinear handles (where they're locked at 180° angles from one another).
/// This gets read in `graph_operation_message_handler.rs` by calling `inputs.as_mut_slice()` (search for the string `"Shape does not have both `subpath` and `colinear_manipulators` inputs"` to find it).
pub colinear_manipulators: Vec<[HandleId; 2]>,
@@ -31,7 +28,6 @@ pub struct Vector {
impl Default for Vector {
fn default() -> Self {
Self {
stroke: Some(Stroke::new(0.)),
colinear_manipulators: Vec::new(),
point_domain: PointDomain::new(),
segment_domain: SegmentDomain::new(),
@@ -45,7 +41,6 @@ impl graphene_hash::CacheHash for Vector {
self.point_domain.cache_hash(state);
self.segment_domain.cache_hash(state);
self.region_domain.cache_hash(state);
self.stroke.cache_hash(state);
self.colinear_manipulators.cache_hash(state);
}
}
@@ -251,10 +246,10 @@ impl Vector {
/// identity (`Inside` = 0, `Outside` = 2×weight): the renderer masks half of a centered double-width
/// stroke, so its AABB matches the unmasked centered stroke's. For open paths the renderer always
/// draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here.
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2, stroke: Option<&Stroke>) -> Option<[DVec2; 2]> {
let path_bounds = self.bounding_box_with_transform(transform);
let Some(stroke) = self.stroke.as_ref() else { return path_bounds };
let Some(stroke) = stroke else { return path_bounds };
// Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall
// back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync.
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed());
@@ -542,40 +537,14 @@ impl Vector {
self.segment_domain.concat(&additional.segment_domain, transform_of_additional, &id_map);
self.region_domain.concat(&additional.region_domain, transform_of_additional, &id_map);
// TODO: properly deal with fills such as gradients
self.stroke = additional.stroke.clone();
self.colinear_manipulators.extend(additional.colinear_manipulators.iter().copied());
}
pub fn set_stroke_transform(&mut self, transform: DAffine2) {
if let Some(stroke) = &mut self.stroke {
stroke.transform = transform;
}
}
}
impl BoundingBox for Vector {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
if !include_stroke {
// Just use the path bounds without stroke
return match self.bounding_box_with_transform(transform) {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
};
}
// Include stroke by adding offset based on stroke width
let stroke = self.stroke.clone();
let stroke_width = stroke.as_ref().map(|s| s.weight()).unwrap_or_default();
let miter_limit = stroke.as_ref().map(|s| s.join_miter_limit).unwrap_or(1.);
let scale = transform.scale_magnitudes();
// Use the full line width to account for different styles of stroke caps
let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
match self.bounding_box_with_transform(transform) {
Some([a, b]) => RenderBoundingBox::Rectangle([a - offset, b + offset]),
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
+2 -16
View File
@@ -11,9 +11,9 @@ use core_types::registry::types::Angle;
use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color, Ctx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DVec2};
use graphic_types::Vector;
use graphic_types::graphic::{Graphic, GraphicLevel, RowStep, TryFromGraphic, is_lone_anonymous_leaf, walk_vector_rows};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::{ATTR_FILL, ATTR_STROKE, Vector};
use graphic_types::markers::EditorMergedLayers;
use raster_types::{CPU, GPU, Raster};
use vector_types::gradient::{GradientForm as GradientFormValue, GradientHueDirection, GradientSpace, GradientSpread};
use vector_types::markers::GradientCyclic;
@@ -424,8 +424,6 @@ fn merged_layers_snapshot<'e>(arena: &'e Arena, mut snapshot: List<Graphic<'stat
type FlattenedVectorRow<'a, 'e> = (
Lane<'a, Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
Attr<'e, EditorLayerPath>,
@@ -436,14 +434,6 @@ type FlattenedVectorRow<'a, 'e> = (
/// walk's composition, paint and layer path overriding, and `snapshot` parked
/// as the merged layers where given.
fn emit_vector_row<'a, 'e>(arena: &'e Arena, carrier: Lane<'a, Graphic<'static>>, row: List<Vector>, snapshot: Option<List<Graphic<'static>>>) -> Result<FlattenedVectorRow<'a, 'e>, Interrupt> {
let park_paint = |paint: Option<&Option<List<Graphic<'static>>>>| {
paint
.and_then(|paint| paint.as_ref())
.map(|paint| arena.alloc_sized_keyed(paint.clone(), 0).map(|(parked, _)| parked).ok_or_else(arena_exhausted))
.transpose()
};
let fill = park_paint(row.attribute(ATTR_FILL, 0))?;
let stroke = park_paint(row.attribute(ATTR_STROKE, 0))?;
let layer_path: Vec<NodeId> = row.attribute(ATTR_EDITOR_LAYER_PATH, 0).cloned().unwrap_or_default();
let (layer_path, _) = arena.alloc(layer_path).ok_or_else(arena_exhausted)?;
@@ -454,8 +444,6 @@ fn emit_vector_row<'a, 'e>(arena: &'e Arena, carrier: Lane<'a, Graphic<'static>>
Ok((
carrier.map_element(element),
Attr(transform),
Attr(fill),
Attr(stroke),
Attr(row.attribute_cloned_or(ATTR_OPACITY, 0, 1.)),
Attr(row.attribute_cloned_or(ATTR_OPACITY_FILL, 0, 1.)),
Attr(layer_path.as_slice()),
@@ -474,8 +462,6 @@ pub fn flatten_vector<'e>(
IList<(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
Attr<'e, EditorLayerPath>,
+1 -1
View File
@@ -12,7 +12,7 @@ pub use graphene_application_io as application_io;
pub use graphene_core;
pub use graphene_core::debug;
pub use graphic_nodes;
pub use graphic_types::{Artboard, Graphic, Vector};
pub use graphic_types::{Appearance, Artboard, Cover, Coverage, Graphic, Vector};
pub use math_nodes;
pub use path_bool_nodes;
pub use raster_nodes;
+4 -4
View File
@@ -43,7 +43,7 @@ pub struct CacheKey {
pub for_mask: bool,
pub thumbnail: bool,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub stroke_below: bool,
pub animation_time_ms: i64,
pub real_time_ms: i64,
pub pointer: [u8; 16],
@@ -60,7 +60,7 @@ impl CacheKey {
for_mask: bool,
thumbnail: bool,
aligned_strokes: bool,
override_paint_order: bool,
stroke_below: bool,
animation_time: f64,
real_time: f64,
pointer: Option<DVec2>,
@@ -85,7 +85,7 @@ impl CacheKey {
for_mask,
thumbnail,
aligned_strokes,
override_paint_order,
stroke_below,
animation_time_ms: (animation_time * 1000.).round() as i64,
real_time_ms: (real_time * 1000.).round() as i64,
pointer: pointer_bytes,
@@ -360,7 +360,7 @@ pub fn render_output_cache(
render_params.for_mask,
render_params.thumbnail,
render_params.aligned_strokes,
render_params.override_paint_order,
render_params.stroke_below,
ctx.try_animation_time().unwrap_or(0.),
ctx.try_real_time().unwrap_or(0.),
ctx.try_pointer_position(),
+44 -46
View File
@@ -3,12 +3,13 @@ use core_types::list::{Item, List};
use core_types::node::Lane;
use core_types::{ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Ctx};
use glam::{DAffine2, DVec2};
use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms, is_paint_present, set_paint_attribute_at};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke};
use graphic_types::appearance::Appearance;
use graphic_types::graphic::{GraphicLevel, PaintColumns, PaintReach, bake_paint_transforms};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::vector_types::subpath::{ManipulatorGroup, Subpath};
use graphic_types::vector_types::vector::PointId;
use graphic_types::vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
use graphic_types::{ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList, Vector};
use graphic_types::{Graphic, IntoGraphicList, Vector};
use linesweeper::topology::Topology;
use linesweeper::{BinaryOp, FillRule, binary_op};
use smallvec::SmallVec;
@@ -25,7 +26,7 @@ fn boolean_core<'e>(
flattened: List<Vector>,
snapshot: List<Graphic<'static>>,
operation: BooleanOperation,
) -> Result<(Vector, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Vector, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
// The first index is the bottom of the stack
let mut result_vector_list = boolean_operation_on_vector_list(&flattened, operation);
@@ -36,7 +37,6 @@ fn boolean_core<'e>(
let result_vector = result_vector_list.element_mut(0).unwrap();
Vector::transform(result_vector, transform);
result_vector.set_stroke_transform(DAffine2::IDENTITY);
// Clean up the boolean operation result by merging duplicated points
let merge_transform: DAffine2 = result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
@@ -49,17 +49,12 @@ fn boolean_core<'e>(
trace: Vec::new(),
})
};
let park_paint = |paint: Option<List<Graphic<'static>>>| -> Result<Option<&'e List<Graphic>>, core_types::gpoll::Interrupt> {
match paint {
Some(list) => Ok(Some(arena.alloc_sized_keyed(list, 0).ok_or_else(exhausted)?.0)),
None => Ok(None),
}
};
let element = result_vector_list.element(0).cloned().unwrap_or_default();
use core_types::lane::LaneSource;
let fill = park_paint(result_vector_list.attr::<Fill>(0).filter(|paint| is_paint_present(paint)).cloned())?;
let stroke = park_paint(result_vector_list.attr::<Stroke>(0).filter(|paint| is_paint_present(paint)).cloned())?;
let appearance = match result_vector_list.attr::<AppearanceMarker>(0).cloned() {
Some(appearance) => Some(arena.alloc_sized_keyed(appearance, 0).ok_or_else(exhausted)?.0),
None => None,
};
// Snapshot the input layers so the renderer can recurse into them for
// editor click-target preservation.
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
@@ -69,8 +64,7 @@ fn boolean_core<'e>(
Ok((
element,
Attr(result_vector_list.attribute_cloned_or_default(ATTR_TRANSFORM, 0)),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(Some(merged_layers)),
))
}
@@ -88,17 +82,17 @@ fn boolean_operation<'e>(
/// Intersection cuts away all but the overlapping areas shared by every path.
/// Difference cuts away the overlapping areas shared by every path, leaving only the non-overlapping areas.
operation: BooleanOperation,
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
if content.is_empty() {
return Err(core_types::gpoll::GraphError::past_end().into());
}
let item = content.as_group_item();
let flattened = flatten_vector_run(GraphicLevel::Run(&item), Ancestors::NONE, PaintReach::NONE);
let snapshot = graphic_types::graphic::run_to_list::<Graphic>(&item).expect("the run holds the row's element type").into_graphic_list();
let (element, transform, fill, stroke, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
let (element, transform, appearance, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
// The merge presents the bottom-of-stack lane's blending, clipping and layer
// path, carried rather than re-read: one named lane instead of four copies.
Ok((content.lane(0).map_element(element), transform, fill, stroke, merged))
Ok((content.lane(0).map_element(element), transform, appearance, merged))
}
/// The boolean operation over a plain vector level, as [`boolean_operation`].
@@ -107,15 +101,15 @@ fn boolean_operation_vector<'e>(
ctx: impl Ctx + ExtractArena<'e> + core_types::InjectIndex + Copy,
content: IList<Vector>,
operation: BooleanOperation,
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, Fill>, Attr<'e, Stroke>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
) -> Result<(Lane<Vector>, Attr<'e, TransformAttr>, Attr<'e, AppearanceMarker>, Attr<'e, EditorMergedLayers>), core_types::gpoll::Interrupt> {
if content.is_empty() {
return Err(core_types::gpoll::GraphError::past_end().into());
}
let item = content.as_group_item();
let flattened = graphic_types::graphic::run_to_list::<Vector>(&item).expect("the run holds vector lanes");
let snapshot = graphic_types::graphic::run_to_list::<Vector>(&item).expect("the run holds the row's element type").into_graphic_list();
let (element, transform, fill, stroke, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
Ok((content.lane(0).map_element(element), transform, fill, stroke, merged))
let (element, transform, appearance, merged) = boolean_core(ctx.arena(), flattened, snapshot, operation)?;
Ok((content.lane(0).map_element(element), transform, appearance, merged))
}
pub use _boolean_operation_vector_mod::boolean_operation_vector_entries;
@@ -203,12 +197,7 @@ fn boolean_operation_on_vector_list(vector: &List<Vector>, boolean_operation: Bo
bake_paint_transforms(&mut attributes, copy_from_transform);
let copy_from = vector.element(index).unwrap();
let element = Vector {
stroke: copy_from.stroke.clone(),
..Default::default()
};
Item::from_parts(element, attributes)
Item::from_parts(Vector::default(), attributes)
} else {
Item::<Vector>::default()
};
@@ -312,13 +301,7 @@ impl Ancestors {
fn push_leaf_vector_row(out: &mut List<Vector>, level: GraphicLevel<'_>, index: usize, vector: &Vector, ancestors: Ancestors, reach: PaintReach<'_>) {
let out_index = out.len();
out.push(Item::from_parts(vector.clone(), graphic_types::graphic::lane_attributes(level, index)));
if reach.applies() {
for (key, slot) in [(ATTR_FILL, reach.paint.fill), (ATTR_STROKE, reach.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, out_index, key, paint.clone());
}
}
}
stamp_inherited_appearance(out, out_index, reach.appearance);
ancestors.compose(out, out_index);
}
@@ -327,13 +310,7 @@ fn push_vector_rows(out: &mut List<Vector>, rows: &List<Vector>, composed: Ances
let Some(item) = rows.clone_item(row) else { continue };
let index = out.len();
out.push(item);
if reach.applies() {
for (key, slot) in [(ATTR_FILL, reach.paint.fill), (ATTR_STROKE, reach.paint.stroke)] {
if let Some(paint) = slot {
set_paint_attribute_at(out, index, key, paint.clone());
}
}
}
stamp_inherited_appearance(out, index, reach.appearance);
composed.compose(out, index);
}
}
@@ -347,6 +324,15 @@ fn push_text_rows(out: &mut List<Vector>, text: &List<String>, composed: Ancesto
}
}
/// The cascade's resolved appearance lands on a row whose own is undeclared, since a declared row wins wholesale.
fn stamp_inherited_appearance(out: &mut List<Vector>, index: usize, inherited: Option<&Appearance>) {
if let Some(appearance) = inherited
&& out.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index).and_then(Appearance::declared).is_none()
{
out.set_attribute(graphic_types::ATTR_APPEARANCE, index, appearance.clone());
}
}
fn push_union(out: &mut List<Vector>, flattened: List<Vector>) {
// The union emits one blank operand even from an empty list, which would fabricate a region out of nothing
if flattened.len() == 0 {
@@ -375,7 +361,7 @@ fn flatten_vector_run_into<'a>(out: &mut List<Vector>, level: GraphicLevel<'a>,
let composed = ancestors.through(&level, index);
match element {
Graphic::Vector(vector) => push_leaf_vector_row(out, level, index, vector, ancestors, reach),
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach.nested())),
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach)),
Graphic::Group(group) => flatten_group(out, group, composed, reach),
Graphic::Text(text) => {
let one = List::new_from_item(Item::from_parts(text.clone(), graphic_types::graphic::lane_attributes(level, index)));
@@ -395,7 +381,7 @@ fn flatten_group(out: &mut List<Vector>, group: &core_types::record::Group, comp
if let Some(rows) = graphic_types::graphic::run_to_list::<Vector>(item) {
push_vector_rows(out, &rows, composed, reach);
} else if core_types::record::RunView::<Graphic>::new(item).is_some() {
push_union(out, flatten_vector_run(GraphicLevel::Run(item), composed, reach.into_group_graphics()));
push_union(out, flatten_vector_run(GraphicLevel::Run(item), composed, reach));
} else if let Some(text) = graphic_types::graphic::run_to_list::<String>(item) {
push_text_rows(out, &text, composed);
}
@@ -502,6 +488,12 @@ mod tests {
List::new_from_element(Graphic::Color(Color::BLACK))
}
/// The single-fill appearance a built row paints with.
fn fill_appearance(paint: List<Graphic<'static>>) -> Appearance {
use graphic_types::appearance::Coverage;
Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(paint))
}
#[test]
fn the_native_flatten_reads_lanes_groups_and_reach() {
let inner_vector = square(DVec2::ZERO);
@@ -515,7 +507,7 @@ mod tests {
top.push(Item::new_from_element(Graphic::Color(Color::BLACK)));
top.push(Item::new_from_element(Graphic::Group(Group { row: None, content: inner_item })));
top.set_attribute(ATTR_TRANSFORM, 0, DAffine2::from_translation(DVec2::new(5., 5.)));
set_paint_attribute_at(&mut top, 0, ATTR_FILL, black_paint());
top.set_attribute(graphic_types::ATTR_APPEARANCE, 0, fill_appearance(black_paint()));
top.set_attribute(ATTR_OPACITY, 1, 0.5);
top.set_attribute(ATTR_TRANSFORM, 2, DAffine2::from_scale(DVec2::splat(3.)));
@@ -523,10 +515,16 @@ mod tests {
// The color lane bounds no region, so it serves no operand and the group's row lands at 1
assert_eq!(rows.len(), 2);
let fill_of = |index: usize| {
rows.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index)
.and_then(|appearance| appearance.first_paint_of(graphic_types::appearance::Cover::Fill))
.and_then(graphic_types::graphic::paint_cell_rows)
};
// Lane 0: the leaf row keeps its lane attributes, with the lane fill
// present and the ancestor composition the identity.
assert_eq!(rows.attribute_cloned_or_default::<DAffine2>(ATTR_TRANSFORM, 0), DAffine2::from_translation(DVec2::new(5., 5.)));
assert!(graphic_types::graphic::paint_graphics::<Fill, _>(&rows, 0).is_some());
assert!(fill_of(0).is_some());
// Lane 1: the group's vector run serves its row under the lane
// transform.
+238 -263
View File
@@ -17,10 +17,11 @@ use core_types::transform::Transform;
use core_types::uuid::NodeId;
use core_types::{ATTR_BLEND_MODE, ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, CacheHash, Color, Ctx, DeriveCtx, ExtractIndex, InjectIndex};
use glam::{DAffine2, DMat2, DVec2};
use graphic_types::graphic::{MapVectorContent, bake_paint_transforms, has_paint, is_paint_present, set_paint_attribute_at};
use graphic_types::markers::{EditorMergedLayers, Fill, Stroke as StrokeAttr};
use graphic_types::appearance::{Appearance, Cover, CoverPlacement, Coverage};
use graphic_types::graphic::{MapVectorContent, bake_paint_transforms, is_paint_present};
use graphic_types::markers::{Appearance as AppearanceMarker, EditorMergedLayers};
use graphic_types::raster_types::{CPU, GPU, Raster};
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE, Graphic, IntoGraphicList};
use graphic_types::{ATTR_EDITOR_MERGED_LAYERS, Graphic, IntoGraphicList};
use graphic_types::{Artboard, Vector};
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
@@ -39,7 +40,7 @@ use vector_types::vector::misc::{
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,
};
use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{DashPattern, Gradient, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::vector::{PointDomain, RegionDomain};
@@ -91,18 +92,18 @@ fn assign_colors<'e>(
/// The number of elements to span across the gradient before repeating. A 0 value will span the entire gradient once.
#[widget(ParsedWidgetOverride::Custom = "assign_colors_repeat_every")]
repeat_every: u32,
) -> Result<IList<(Lane<Vector>, Attr<'e, Fill>, Attr<'e, StrokeAttr>)>, Interrupt> {
) -> Result<IList<(Lane<Vector>, Attr<'e, AppearanceMarker>)>, Interrupt> {
let lane = ctx.index() as usize;
if lane >= content.len() {
return Err(GraphError::past_end().into());
}
let element = content.element_ref(lane).clone();
let park_existing = |paint: Option<&List<Graphic<'static>>>| -> Result<Option<&'e List<Graphic>>, Interrupt> { paint.map(|paint| park_paint(ctx.arena(), paint.clone())).transpose() };
let existing_fill = park_existing(content.lane(lane).attr::<Fill>())?;
let existing_stroke = park_existing(content.lane(lane).attr::<StrokeAttr>())?;
let existing_appearance = content.lane(lane).attr::<AppearanceMarker>().cloned();
let park_appearance_attr = |appearance: Option<Appearance>| -> Result<Option<&'e Appearance>, Interrupt> { appearance.map(|appearance| park_appearance(ctx.arena(), appearance)).transpose() };
if gradient.is_empty() {
return Ok((content.lane(lane).map_element(element), Attr(existing_fill), Attr(existing_stroke)));
let parked_appearance = park_appearance_attr(existing_appearance)?;
return Ok((content.lane(lane).map_element(element), Attr(parked_appearance)));
}
let settings = vector_types::GradientSettings {
spread: gradient.lane(0).attr::<vector_types::markers::GradientSpread>(),
@@ -123,17 +124,20 @@ fn assign_colors<'e>(
let color = assign_color_at(gradient_element, settings, lane, content.len(), randomize, seed, repeat_every);
let paint = List::new_from_element(color).into_graphic_list();
let parked = park_paint(ctx.arena(), paint)?;
let fill_attr = match fill {
true => Some(parked),
false => existing_fill,
};
let stroke_attr = match stroke && element.stroke.is_some() {
true => Some(parked),
false => existing_stroke,
};
Ok((content.lane(lane).map_element(element), Attr(fill_attr), Attr(stroke_attr)))
// The recolor lands on the appearance's coverage paints
let mut appearance = existing_appearance.unwrap_or_default();
let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
}
// The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
}
let parked_appearance = park_appearance_attr(Some(appearance))?;
Ok((content.lane(lane).map_element(element), Attr(parked_appearance)))
}
#[allow(clippy::too_many_arguments)]
@@ -230,15 +234,20 @@ fn assign_colors_graphic<'e>(
let element = match rows {
Some(mut rows) => {
for row in 0..rows.len() {
let has_stroke = rows.element(row).is_some_and(|vector| vector.stroke.is_some());
let color = assign_color_at(gradient_element, settings, position + row, length, randomize, seed, repeat_every);
let paint = List::new_from_element(color).into_graphic_list();
if fill {
set_paint_attribute_at(&mut rows, row, ATTR_FILL, paint.clone());
// The recolor lands on the row's appearance coverage paints
let mut appearance = rows.attribute_cloned_or_default::<Appearance>(graphic_types::ATTR_APPEARANCE, row);
let paint_cell = Graphic::Graphic(paint);
if fill && !appearance.set_paint_of(Cover::Fill, paint_cell.clone()) {
appearance.replace_or_insert(Coverage::new_fill(), paint_cell.clone(), CoverPlacement::Below);
}
if stroke && has_stroke {
set_paint_attribute_at(&mut rows, row, ATTR_STROKE, paint.clone());
// The stroke recolor is gated on an existing stroke coverage, since restyling never adds a stroke
if stroke {
appearance.set_paint_of(Cover::Stroke, paint_cell);
}
rows.set_attribute(graphic_types::ATTR_APPEARANCE, row, appearance);
}
let content = core_types::record::GroupItem::from_list(rows, ctx.arena()).ok_or_else(|| Interrupt::from(GraphError::new("the arena is exhausted")))?;
Graphic::Group(core_types::record::Group { row: None, content })
@@ -278,16 +287,27 @@ fn assign_colors_graphic_extent(
pub use _assign_colors_graphic_mod::assign_colors_graphic_entries;
/// Keyed, so a group-free paint's promote moves this header rather than
/// Keyed, so a group-free appearance's promote moves this header rather than
/// cloning the content it owns.
fn park_paint<'e>(arena: &'e core_types::arena::Arena, paint: List<Graphic<'static>>) -> Result<&'e List<Graphic<'static>>, Interrupt> {
let (parked, _) = arena.alloc_sized_keyed(paint, 0).ok_or(GraphError {
/// Keyed so a group-free appearance's promote moves this header.
fn park_appearance(arena: &core_types::arena::Arena, appearance: Appearance) -> Result<&Appearance, Interrupt> {
let (parked, _) = arena.alloc_sized_keyed(appearance, 0).ok_or(GraphError {
kind: core_types::gpoll::ErrorKind::ArenaExhausted,
trace: Vec::new(),
})?;
Ok(parked)
}
/// Appends one coverage to the content's appearance following the painter's algorithm:
/// the most downstream paint node in the chain paints on top unless it asks for the
/// below placement. The coverage's paint is the canonical paint list carried as one
/// graphic cell, the input lane's own envelope dropped.
fn stamped_appearance(content_appearance: Option<&Appearance>, coverage: Coverage, paint: &List<Graphic<'static>>, placement: CoverPlacement) -> Appearance {
let mut appearance = content_appearance.cloned().unwrap_or_default();
appearance.replace_or_insert(coverage, Graphic::Graphic(paint.clone()), placement);
appearance
}
/// The gradient defaulting the legacy fill performed, applied to the nested
/// stops list the paint table wraps.
fn default_gradient_paint(paint: &mut List<Graphic>, bounds: Option<[DVec2; 2]>, gradient_type: GradientForm, transform: Option<DAffine2>) {
@@ -329,7 +349,7 @@ fn paint_table(paint: core_types::node::List<'_, Graphic<'_>>) -> List<Graphic<'
fn fill<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
/// The content with vector paths to apply the fill style to.
(element, _content_fill): (Vector, Attr<Fill>),
(element, content_appearance): (Vector, Attr<AppearanceMarker>),
/// The fill to paint the path with.
#[default(Color::BLACK)]
fill: IList<Graphic<'static>>,
@@ -338,35 +358,36 @@ fn fill<'e>(
_gradient_form: GradientForm,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Vector, Attr<'e, Fill>), Interrupt> {
) -> Result<(Vector, Attr<'e, AppearanceMarker>), Interrupt> {
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_form, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(Some(parked_appearance))))
}
/// The fill over graphic lanes: the marker parks on the lane and the render
/// boundary moves it onto the interior vector lists the legacy paint readers
/// inspect. Registered under the fill's identifier.
/// The fill over graphic lanes: the appearance parks on the lane and cascades
/// to the vectors beneath it. Registered under the fill's identifier.
#[node_macro::node(category(""))]
fn fill_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, _content_fill): (Graphic<'static>, Attr<Fill>),
(element, content_appearance): (Graphic<'static>, Attr<AppearanceMarker>),
#[default(Color::BLACK)] fill: IList<Graphic<'static>>,
_backup_color: IList<Color>,
_backup_gradient: IList<Gradient>,
_gradient_form: GradientForm,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Graphic<'static>, Attr<'e, Fill>), Interrupt> {
) -> Result<(Graphic<'static>, Attr<'e, AppearanceMarker>), Interrupt> {
let bounds = match BoundingBox::bounding_box(&element, DAffine2::IDENTITY, false) {
RenderBoundingBox::Rectangle(bounds) => Some(bounds),
_ => None,
};
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, bounds, _gradient_form, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
let appearance = stamped_appearance(*content_appearance, Coverage::new_fill(), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(Some(parked_appearance))))
}
/// Applies a stroke style to the vector content, giving an appearance to the area within the outline of the geometry.
@@ -374,7 +395,7 @@ fn fill_graphic_leveled<'e>(
fn stroke<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
/// The content with vector paths to apply the stroke style to.
(element, content_transform): (Vector, Attr<TransformAttr>),
(element, content_transform, content_appearance): (Vector, Attr<TransformAttr>, Attr<AppearanceMarker>),
/// The stroke paint.
#[default(Color::BLACK)]
paint: IList<Graphic<'static>>,
@@ -391,72 +412,12 @@ fn stroke<'e>(
/// 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.)]
miter_limit: f64,
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
paint_order: 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.
dash_pattern: DashPattern,
/// The phase offset distance from the starting point of the dash pattern.
#[unit(" px")]
dash_offset: f64,
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let mut stroke = Stroke {
weight,
dash_lengths,
dash_offset,
cap,
join,
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
stroke.transform *= *content_transform;
let mut element = element;
element.stroke = Some(stroke);
let paint = paint_table(paint);
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(*content_transform), Attr(Some(parked))))
}
/// The vector items of a graphic lane's interior, one wrap level deep, the
/// reach of the pre-flip broadcast over a legacy list.
fn for_each_interior_vector_mut(element: &mut Graphic, mut f: impl FnMut(&mut Vector, DAffine2)) {
match element {
Graphic::Vector(vector) => f(vector, DAffine2::IDENTITY),
Graphic::Graphic(children) => {
for index in 0..children.len() {
let transform: DAffine2 = children.attribute_cloned_or_default(ATTR_TRANSFORM, index);
if let Some(Graphic::Vector(vector)) = children.element_mut(index) {
f(vector, transform);
}
}
}
_ => {}
}
}
/// The stroke over graphic lanes: the style applies to the interior vectors,
/// the paint marker parks on the lane for the render boundary to place.
/// Registered under the stroke's identifier.
#[node_macro::node(category(""))]
fn stroke_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, content_transform): (Graphic<'static>, Attr<TransformAttr>),
#[default(Color::BLACK)] paint: IList<Graphic<'static>>,
#[unit(" px")]
#[default(2.)]
weight: f64,
align: StrokeAlign,
cap: StrokeCap,
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
paint_order: PaintOrder,
dash_pattern: DashPattern,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
) -> Result<(Vector, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let stroke = Stroke {
weight,
@@ -467,19 +428,57 @@ fn stroke_graphic_leveled<'e>(
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
paint_order,
};
let mut element = element;
for_each_interior_vector_mut(&mut element, |vector, transform| {
let mut stroke = stroke.clone();
stroke.transform *= transform;
vector.stroke = Some(stroke);
});
// The coverage records the stroke's authoring space: the item transform, translation included
let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform;
let paint = paint_table(paint);
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(*content_transform), Attr(Some(parked))))
// A below stroke is the chain running the stroke node before the fill, so the coverage appends above
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(*content_transform), Attr(Some(parked_appearance))))
}
/// The stroke over graphic lanes: the appearance parks on the lane and cascades
/// to the vectors beneath it. Registered under the stroke's identifier.
#[node_macro::node(category(""))]
fn stroke_graphic_leveled<'e>(
ctx: impl Ctx + ExtractArena<'e> + ExtractIndex + InjectIndex + Copy,
(element, content_transform, content_appearance): (Graphic<'static>, Attr<TransformAttr>, Attr<AppearanceMarker>),
#[default(Color::BLACK)] paint: IList<Graphic<'static>>,
#[unit(" px")]
#[default(2.)]
weight: f64,
align: StrokeAlign,
cap: StrokeCap,
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
dash_pattern: DashPattern,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, AppearanceMarker>), Interrupt> {
let dash_lengths = dash_pattern.clamped_lengths();
let stroke = Stroke {
weight,
dash_lengths,
dash_offset,
cap,
join,
join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY,
};
// The coverage records the stroke's authoring space: the lane transform, translation included
let mut coverage_stroke = stroke;
coverage_stroke.transform *= *content_transform;
let paint = paint_table(paint);
// A below stroke is the chain running the stroke node before the fill, so the coverage appends above
let appearance = stamped_appearance(*content_appearance, Coverage::new_stroke(&coverage_stroke), &paint, CoverPlacement::Above);
let parked_appearance = park_appearance(ctx.arena(), appearance)?;
Ok((element, Attr(*content_transform), Attr(Some(parked_appearance))))
}
pub use _fill_graphic_leveled_mod::fill_graphic_leveled_entries;
@@ -630,10 +629,7 @@ fn round_corners<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
// Convert 0-100 to 0-0.5
let edge_length_limit = edge_length_limit * 0.005;
let mut result = Vector {
stroke: source.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
// Grab the initial point ID as a stable starting point
let mut initial_point_id = source.point_domain.ids().first().copied().unwrap_or(PointId::generate());
@@ -1014,8 +1010,6 @@ fn box_warp<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
});
}
result.set_stroke_transform(DAffine2::IDENTITY);
// Reset the transform since we've applied it directly to the points
(result, DAffine2::IDENTITY)
})?;
@@ -1163,10 +1157,7 @@ fn auto_tangents<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
preserve_existing: bool,
) -> Result<(V::Live<'e>, Attr<TransformAttr>), Interrupt> {
let (source, transform) = map_vectors(ctx.arena(), source, *lane_transform, |source, transform| {
let mut result = Vector {
stroke: source.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut subpath in source.stroke_bezier_paths() {
subpath.apply_transform(transform);
@@ -1305,7 +1296,7 @@ fn bounding_box<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'sta
#[implementations(Graphic, Vector)] content: V,
) -> Result<V::Live<'e>, Interrupt> {
let (content, _) = map_vectors(ctx.arena(), content, DAffine2::IDENTITY, |vector, transform| {
let mut result = vector
let result = vector
.bounding_box_rect()
.map(|bbox| {
let mut vector = Vector::default();
@@ -1314,9 +1305,6 @@ fn bounding_box<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'sta
})
.unwrap_or_default();
result.stroke = vector.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
(result, transform)
})?;
@@ -1556,11 +1544,7 @@ fn offset_path<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'stat
let transform = Affine::new(transform_attribute.to_cols_array());
let bezpaths = vector.stroke_bezpath_iter();
let mut result = Vector {
stroke: vector.stroke.clone(),
..Default::default()
};
result.set_stroke_transform(DAffine2::IDENTITY);
let mut result = Vector { ..Default::default() };
// Perform operation on all subpaths in this shape.
for mut bezpath in bezpaths {
@@ -1593,16 +1577,23 @@ fn offset_path<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'stat
fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
// TODO: Make this node support stroke align, which it currently ignores
// A fill exists when the canonical attribute carries paint
let has_fills: Vec<bool> = (0..flattened.len()).map(|index| has_paint::<Fill, _>(&flattened, index)).collect();
// A fill exists when the row's appearance carries a painted fill coverage
let has_fills: Vec<bool> = (0..flattened.len())
.map(|index| {
flattened
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, index)
.is_some_and(|appearance| appearance.has_painted_cover(Cover::Fill))
})
.collect();
let output: List<Vector> = flattened
.into_iter()
.zip(has_fills)
.flat_map(|(row, has_fill)| {
let (mut vector, attributes) = row.into_parts();
let (vector, attributes) = row.into_parts();
let stroke = vector.stroke.clone().unwrap_or_default();
let appearance = attributes.get::<Appearance>(graphic_types::ATTR_APPEARANCE).cloned().unwrap_or_default();
let stroke = appearance.first_coverage_of(Cover::Stroke).map(Coverage::stroke_params).unwrap_or_default();
let bezpaths = vector.stroke_bezpath_iter();
let mut solidified_stroke = Vector::default();
@@ -1620,7 +1611,11 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths;
let miter_limit = stroke.join_miter_limit;
let paint_order = stroke.paint_order;
// The paint order rides the row's coverage order
let stroke_below = attributes
.get::<Appearance>(graphic_types::ATTR_APPEARANCE)
.map(Appearance::fill_and_stroke)
.is_some_and(|resolved| resolved.stroke_below);
let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap)
@@ -1649,26 +1644,32 @@ fn solidify_rows(flattened: List<Vector>) -> List<Vector> {
solidified_stroke.append_bezpath(solidified);
}
// If the original vector has a fill, preserve it as a separate item with the stroke cleared.
// If the original vector has a fill, preserve it as a separate item with the stroke coverages dropped.
let fill_row = has_fill.then(|| {
vector.stroke = None;
let mut fill_attributes = attributes.clone();
// No stroke remains on the fill row
fill_attributes.remove::<Option<List<Graphic>>>(ATTR_STROKE);
if let Some(appearance) = fill_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) {
appearance.retain_cover(Cover::Fill);
}
Item::from_parts(vector, fill_attributes)
});
let mut stroke_attributes = attributes;
// Drop the original fill and use the stroke paint to fill the outlined stroke
stroke_attributes.remove::<Option<List<Graphic>>>(ATTR_FILL);
stroke_attributes.rename(ATTR_STROKE, ATTR_FILL);
if let Some(appearance) = stroke_attributes.get_mut::<Appearance>(graphic_types::ATTR_APPEARANCE) {
// The outlined stroke is filled with the stroke coverage's paint
let stroke_paint = appearance.first_paint_of(Cover::Stroke).cloned();
*appearance = Appearance::default();
if let Some(paint) = stroke_paint {
appearance.replace_or_insert(Coverage::new_fill(), paint, CoverPlacement::Above);
}
}
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.
match paint_order {
PaintOrder::StrokeAbove => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
PaintOrder::StrokeBelow => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
// Ordering based on the coverage order. The first item in the `List` is rendered below the second.
match stroke_below {
false => fill_row.into_iter().chain(std::iter::once(stroke_row)).collect::<Vec<_>>(),
true => std::iter::once(stroke_row).chain(fill_row).collect::<Vec<_>>(),
}
})
.collect();
@@ -1689,8 +1690,7 @@ fn solidify_native_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1749,8 +1749,7 @@ fn emit_legacy_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1771,15 +1770,10 @@ fn emit_legacy_lane<'e>(
};
let element = output.element(lane).cloned().unwrap_or_default();
let fill = output
.attribute::<Option<List<Graphic>>>(ATTR_FILL, lane)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
let stroke = output
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, lane)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
let appearance = output
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, lane)
.cloned()
.map(|appearance| park_appearance(arena, appearance))
.transpose()?;
let layer_path: Vec<NodeId> = output.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, lane).cloned().unwrap_or_default();
let layer_path = arena.alloc(layer_path).ok_or_else(exhausted)?.0;
@@ -1792,8 +1786,7 @@ fn emit_legacy_lane<'e>(
Ok((
element,
Attr(output.attribute_cloned_or_default(ATTR_TRANSFORM, lane)),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(output.attribute_cloned_or_default(ATTR_BLEND_MODE, lane)),
Attr(output.attribute_cloned_or(ATTR_OPACITY, lane, 1.)),
Attr(output.attribute_cloned_or(ATTR_OPACITY_FILL, lane, 1.)),
@@ -1839,8 +1832,7 @@ fn solidify_stroke<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1879,8 +1871,7 @@ fn solidify_stroke_vector<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -1925,7 +1916,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
return vec![row];
}
let stroke = row.element().stroke.clone();
let (_, attributes) = row.into_parts();
bezpaths
@@ -1933,7 +1923,6 @@ fn separate_subpaths_core(content: List<Vector>) -> List<Vector> {
.map(|bezpath| {
let mut vector = Vector::default();
vector.append_bezpath(bezpath);
vector.stroke = stroke.clone();
Item::from_parts(vector, attributes.clone())
})
@@ -1951,8 +1940,7 @@ fn separate_subpaths<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2007,8 +1995,7 @@ fn map_points<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2063,8 +2050,7 @@ fn flatten_path_core<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
Option<usize>,
@@ -2087,35 +2073,19 @@ fn flatten_path_core<'e>(
let source_transform = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index);
output.concat(element, source_transform, collision_hash_seed);
// TODO: Make this instead use the first encountered stroke
// Use the last encountered stroke as the output stroke
output.stroke = element.stroke.clone();
primary_source = Some((index, source_transform));
}
let mut fill = None;
let mut stroke = None;
// The primary row's appearance carries over whole, its paint transforms baked
let mut appearance = None;
let mut layer_path = Vec::new();
if let Some((primary, source_transform)) = primary_source {
let source_attributes = flattened.clone_item_attributes(primary);
let mut attributes = ItemAttributeValues::new();
attributes.insert_cloned_from(&source_attributes, ATTR_FILL);
attributes.insert_cloned_from(&source_attributes, ATTR_STROKE);
attributes.insert_cloned_from(&source_attributes, graphic_types::ATTR_APPEARANCE);
bake_paint_transforms(&mut attributes, source_transform);
let carrier = List::new_from_item(Item::from_parts(Vector::default(), attributes));
fill = carrier
.attribute::<Option<List<Graphic>>>(ATTR_FILL, 0)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
stroke = carrier
.attribute::<Option<List<Graphic>>>(ATTR_STROKE, 0)
.and_then(|paint| paint.as_ref())
.map(|paint| park_paint(arena, paint.clone()))
.transpose()?;
appearance = attributes.remove::<Appearance>(graphic_types::ATTR_APPEARANCE).and_then(|appearance| appearance.declared().cloned());
// Adopt the last input item's layer so the editor can also bucket clicks under a contributing child layer
layer_path = flattened.attribute_cloned_or_default::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, primary);
@@ -2131,11 +2101,12 @@ fn flatten_path_core<'e>(
// editor click-target preservation, as the boolean operation does.
let merged_layers = arena.alloc_sized_keyed(snapshot, 0).ok_or_else(exhausted)?.0;
let appearance = appearance.map(|appearance| park_appearance(arena, appearance)).transpose()?;
Ok((
output,
Attr(DAffine2::IDENTITY),
Attr(fill),
Attr(stroke),
Attr(appearance),
Attr(layer_path.as_slice()),
Attr(Some(merged_layers)),
primary_source.map(|(index, _)| index),
@@ -2151,8 +2122,7 @@ pub fn combine_paths<'e>(
(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
),
@@ -2164,12 +2134,12 @@ pub fn combine_paths<'e>(
let item = content.as_group_item();
let (flattened, tops) = flatten_rows_with_top_lanes(graphic_types::graphic::GraphicLevel::Run(&item));
let snapshot = graphic_types::graphic::run_to_list::<Graphic>(&item).expect("the run holds the row's element type");
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
let (element, transform, appearance, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
// The merge presents the blending of the top-level row its last contributing
// path came from, carried rather than re-read. The layer path stays an
// override: it deliberately names the contributing CHILD layer, not the row.
let carrier = primary.and_then(|row| tops.get(row).copied()).unwrap_or(0);
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
Ok((content.lane(carrier).map_element(element), transform, appearance, layer_path, merged))
}
/// The path flattening over a plain vector level, as [`combine_paths`].
@@ -2182,8 +2152,7 @@ pub fn combine_paths_vector<'e>(
(
Lane<Vector>,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, EditorLayerPath>,
Attr<'e, EditorMergedLayers>,
),
@@ -2195,12 +2164,12 @@ pub fn combine_paths_vector<'e>(
let wrapper = wrap_vector_level(content);
let flattened = graphic_types::graphic::flatten_vector_rows(graphic_types::graphic::GraphicLevel::Legacy(&wrapper));
let snapshot = legacy_graphic_list_of(content);
let (element, transform, fill, stroke, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
let (element, transform, appearance, layer_path, merged, primary) = flatten_path_core(ctx.arena(), flattened, snapshot)?;
// `top_lane` is degenerate here - the wrapper is one graphic lane holding the
// whole vector run, so every row reports 0. The rows ARE the input lanes in
// order though, so the contributing row index names the lane directly.
let carrier = primary.filter(|row| *row < content.len()).unwrap_or(0);
Ok((content.lane(carrier).map_element(element), transform, fill, stroke, layer_path, merged))
Ok((content.lane(carrier).map_element(element), transform, appearance, layer_path, merged))
}
pub use _combine_paths_vector_mod::combine_paths_vector_entries;
@@ -2235,16 +2204,13 @@ fn sample_polyline<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + '
}
};
let mut element = element;
let mut result = Vector {
point_domain: Default::default(),
segment_domain: Default::default(),
region_domain: Default::default(),
colinear_manipulators: Default::default(),
stroke: std::mem::take(&mut element.stroke),
};
// Transfer the stroke transform from the input vector content to the result.
result.set_stroke_transform(transform);
for local_bezpath in element.stroke_bezpath_iter() {
// Apply the transform to compute sample locations in world space (for correct distance-based spacing)
@@ -2311,10 +2277,7 @@ fn simplify<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: content.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -2405,10 +2368,7 @@ fn decimate<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'static>
let transform = Affine::new(transform_attribute.to_cols_array());
let inverse_transform = transform.inverse();
let mut result = Vector {
stroke: content.stroke.clone(),
..Default::default()
};
let mut result = Vector { ..Default::default() };
for mut bezpath in content.stroke_bezpath_iter() {
bezpath.apply_affine(transform);
@@ -2485,10 +2445,7 @@ fn cut_path_core(mut content: List<Vector>, progression: f64, reverse: bool, par
let index = if t_value >= bezpath_count { (bezpath_count - 1.) as usize } else { t_value as usize };
if let Some((row_index, bezpath)) = bezpaths.get(index).cloned() {
let mut result_vector = Vector {
stroke: content.element(row_index).unwrap().stroke.clone(),
..Default::default()
};
let mut result_vector = Vector::default();
for (_, (_, bezpath)) in bezpaths.iter().enumerate().filter(|(i, (ri, _))| *i != index && *ri == row_index) {
result_vector.append_bezpath(bezpath.clone());
@@ -2527,8 +2484,7 @@ fn cut_path<'e>(
IList<(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -2738,8 +2694,6 @@ fn scatter_points<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 's
}
// Transfer the style from the input vector content to the result.
result.stroke = element.stroke.clone();
result.set_stroke_transform(DAffine2::IDENTITY);
(result, lane)
})?;
@@ -2933,7 +2887,6 @@ fn offset_points<'e, V: MapVectorContent + Clone + Send + Sync + CacheHash + 'st
///
/// *Progression* morphs through all objects. Interpolation is linear unless *Path* geometry is provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments.
fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progression: f64, reverse: bool, distribution: InterpolationDistribution, path: List<Vector>) -> List<Vector> {
use core_types::lane::LaneSource;
/// Promotes a segment's handle pair to cubic-equivalent Bézier control points.
/// For linear segments (both None), handles are placed at their respective anchors (zero-length)
/// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature.
@@ -3092,6 +3045,51 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
}
}
/// Lerps two appearances pairing coverages by cover, so a fill and a stroke never interpolate into each other.
/// Stroke parameter pairs interpolate; other coverage pairings and the paint order step at the midpoint.
fn lerp_appearance(a: Option<&Appearance>, b: Option<&Appearance>, time: f64) -> Option<Appearance> {
if a.is_none() && b.is_none() {
return None;
}
let empty = Appearance::default();
let (a, b) = (a.unwrap_or(&empty), b.unwrap_or(&empty));
// The side holding the paint order at this time leads, so covers only the other side has follow behind it
let (leading, trailing) = if time < 0.5 { (a, b) } else { (b, a) };
let mut covers: Vec<Cover> = Vec::new();
for cover in leading.covers().chain(trailing.covers()).map(Coverage::cover) {
if !covers.contains(&cover) {
covers.push(cover);
}
}
let mut result = Appearance::default();
for cover in covers {
let (source_index, target_index) = (a.first_index_of(cover), b.first_index_of(cover));
// An unmatched stroke steps out at the midpoint, matching the stroke geometry, while an unmatched fill persists and fades
let coverage = match (source_index.and_then(|index| a.cover_at(index)), target_index.and_then(|index| b.cover_at(index))) {
(Some(source), Some(target)) if cover == Cover::Stroke => Coverage::new_stroke(&source.stroke_params().lerp(&target.stroke_params(), time)),
(Some(source), Some(target)) => (if time < 0.5 { source } else { target }).clone(),
(Some(_), None) if cover == Cover::Stroke && time >= 0.5 => continue,
(None, Some(_)) if cover == Cover::Stroke && time < 0.5 => continue,
(Some(source), None) => source.clone(),
(None, Some(target)) => target.clone(),
(None, None) => continue,
};
// An unmatched side falls to `None` here, which `lerp_graphic` fades against transparent.
// The paint cell carries its graphic list as one wrapped cell, so the lerp works on the unwrapped rows.
let source_paint = source_index.and_then(|index| a.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let target_paint = target_index.and_then(|index| b.paint_at(index)).and_then(graphic_types::graphic::paint_cell_rows);
let paint = lerp_graphic(source_paint, target_paint, time).map(Graphic::Graphic).unwrap_or_default();
result.replace_or_insert(coverage, paint, CoverPlacement::Above);
}
Some(result)
}
// Preserve the original legacy snapshot as upstream data so this group layer's nested layers can be edited by the tools.
let mut graphic_list_content = snapshot;
@@ -3349,35 +3347,12 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
return List::new_from_item(Item::from_parts(endpoint_element.clone(), attributes));
}
let stroke = match (source_element.stroke.as_ref(), target_element.stroke.as_ref()) {
(Some(a), Some(b)) => Some(a.lerp(b, time)),
(Some(a), None) => {
if time < 0.5 {
Some(a.clone())
} else {
None
}
}
(None, Some(b)) => {
if time < 0.5 {
None
} else {
Some(b.clone())
}
}
(None, None) => None,
};
let mut vector = Vector { stroke, ..Default::default() };
let mut vector = Vector::default();
let fill_paint = {
let source = content.attr::<Fill>(source_index).filter(|paint| is_paint_present(paint));
let target = content.attr::<Fill>(target_index).filter(|paint| is_paint_present(paint));
lerp_graphic(source, target, time)
};
let stroke_paint = {
let source = content.attr::<StrokeAttr>(source_index).filter(|paint| is_paint_present(paint));
let target = content.attr::<StrokeAttr>(target_index).filter(|paint| is_paint_present(paint));
lerp_graphic(source, target, time)
let appearance = {
let source = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, source_index);
let target = content.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, target_index);
lerp_appearance(source, target, time)
};
// Work directly with manipulator groups, bypassing the BezPath intermediate representation.
@@ -3537,11 +3512,8 @@ fn morph_core(flattened: List<Vector>, snapshot: List<Graphic<'static>>, progres
.with_attribute(ATTR_EDITOR_LAYER_PATH, layer_path)
.with_attribute(ATTR_EDITOR_MERGED_LAYERS, Some(graphic_list_content));
if let Some(fill) = fill_paint {
item.set_attribute(ATTR_FILL, Some(fill));
}
if let Some(stroke) = stroke_paint {
item.set_attribute(ATTR_STROKE, Some(stroke));
if let Some(appearance) = appearance {
item.set_attribute(graphic_types::ATTR_APPEARANCE, appearance);
}
List::new_from_item(item)
@@ -3562,8 +3534,7 @@ fn morph_lane<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -3602,8 +3573,7 @@ fn morph<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -3634,8 +3604,7 @@ fn morph_vector<'e>(
(
Vector,
Attr<'e, TransformAttr>,
Attr<'e, Fill>,
Attr<'e, StrokeAttr>,
Attr<'e, AppearanceMarker>,
Attr<'e, BlendModeAttr>,
Attr<'e, Opacity>,
Attr<'e, OpacityFill>,
@@ -4081,7 +4050,6 @@ fn centroid(_: impl Ctx, vector: IList<Vector>, centroid_type: CentroidType) ->
#[cfg(test)]
mod test {
use super::*;
use graphic_types::graphic::paint_graphics;
use kurbo::{CubicBez, Ellipse, Point, Rect};
use vector_types::vector::algorithms::bezpath_algorithms::{TValue, trim_pathseg};
use vector_types::vector::misc::pathseg_abs_diff_eq;
@@ -4376,12 +4344,13 @@ mod test {
v
};
let fill_appearance = |color: Color| Appearance::new_single(Coverage::new_fill(), Graphic::Graphic(List::new_from_element(color).into_graphic_list()));
let item_a = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::IDENTITY)
.with_attribute(ATTR_FILL, Some(List::new_from_element(Color::RED).into_graphic_list()));
.with_attribute(graphic_types::ATTR_APPEARANCE, fill_appearance(Color::RED));
let item_b = Item::new_from_element(rect())
.with_attribute(ATTR_TRANSFORM, DAffine2::from_translation((-100., -100.).into()))
.with_attribute(ATTR_FILL, Some(List::new_from_element(Color::BLUE).into_graphic_list()));
.with_attribute(graphic_types::ATTR_APPEARANCE, fill_appearance(Color::BLUE));
let mut content = List::new_from_item(item_a);
content.push(item_b);
@@ -4389,7 +4358,13 @@ mod test {
let snapshot = content.into_graphic_list();
let morphed = super::morph_core(snapshot.clone().into_flattened_list(), snapshot, 0.5, false, InterpolationDistribution::default(), List::default());
let fill = paint_graphics::<Fill, _>(&morphed, 0).expect("Morph should keep the fill paint at the midpoint");
let appearance = morphed
.attribute::<Appearance>(graphic_types::ATTR_APPEARANCE, 0)
.expect("Morph should keep the appearance at the midpoint");
let fill = appearance
.first_paint_of(Cover::Fill)
.and_then(graphic_types::graphic::paint_cell_rows)
.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
let Some(Graphic::Color(color)) = fill.element(0) else {