Split Fill's optional transform into a has-transform toggle and add the DashPattern and BoxCorners value types

This commit is contained in:
Keavon Chambers
2026-07-20 16:13:58 -07:00
committed by Dennis Kobert
parent a373fad0ca
commit 1bac1d694f
13 changed files with 449 additions and 122 deletions

View File

@@ -492,9 +492,14 @@ impl<'a> ModifyInputsContext<'a> {
.and_then(|node| node.inputs.get(graphene_std::vector::fill::TransformInput::INDEX))
.is_some_and(|input| input.as_value().is_some());
if transform_is_value {
self.set_input_with_refresh(
InputConnector::node(fill_node_id, graphene_std::vector::fill::HasTransformInput::INDEX),
NodeInput::value(TaggedValue::Bool(true), false),
true,
);
self.set_input_with_refresh(
InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX),
NodeInput::value(TaggedValue::OptionalDAffine2(Some(transform)), false),
NodeInput::value(TaggedValue::DAffine2(transform), false),
true,
);
}
@@ -733,8 +738,8 @@ impl<'a> ModifyInputsContext<'a> {
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::INDEX);
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::DashLengthsInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64Array(stroke.dash_lengths), false), true);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashPatternInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::DashPattern(stroke.dash_lengths.into()), false), true);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::DashOffsetInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.dash_offset), false), true);
}

View File

@@ -31,8 +31,12 @@ use graphene_std::text::{Font, TextAlign};
use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform};
use graphene_std::vector::misc::BooleanOperation;
use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType};
use graphene_std::vector::style::{FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation};
use graphene_std::vector::misc::{
ArcType, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
};
use graphene_std::vector::style::{
DashPattern, FillChoiceUI, Gradient, GradientSpreadMethod, GradientType, GradientUI, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin, build_transform_with_y_preservation,
};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, VectorModification};
pub(crate) fn string_properties(text: &str) -> Vec<LayoutGroup> {
@@ -861,6 +865,32 @@ pub fn array_of_number_widget(parameter_widgets_info: ParameterWidgetsInfo, text
widgets
}
pub fn dash_pattern_widget(parameter_widgets_info: ParameterWidgetsInfo, text_input: TextInput) -> Vec<WidgetInstance> {
let ParameterWidgetsInfo { document_node, node_id, index, .. } = parameter_widgets_info;
let mut widgets = start_widgets(parameter_widgets_info);
let Some(document_node) = document_node else { return Vec::new() };
let Some(input) = document_node.inputs.get(index) else {
log::warn!("A widget failed to be built because its node's input index is invalid.");
return vec![];
};
if let Some(TaggedValue::DashPattern(pattern)) = &input.as_non_exposed_value() {
widgets.extend_from_slice(&[
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
text_input
.value(pattern.0.iter_element_values().map(|length| length.to_string()).collect::<Vec<_>>().join(", "))
.on_update(optionally_update_value(
move |input: &TextInput| Some(TaggedValue::DashPattern(DashPattern::from(input.value.as_str()))),
node_id,
index,
))
.widget_instance(),
])
}
widgets
}
pub fn font_inputs(parameter_widgets_info: ParameterWidgetsInfo) -> (Vec<WidgetInstance>, Option<Vec<WidgetInstance>>) {
pub fn assign_font_message(node_id: NodeId, font: Font) -> Message {
let resource_id = ResourceId::new();
@@ -2205,16 +2235,11 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
log::warn!("A widget failed to be built because its node's input index is invalid.");
return vec![];
};
let uniform_val = match input.as_non_exposed_value() {
Some(TaggedValue::F64(x)) => *x,
Some(TaggedValue::F64Array(values)) => values.first().copied().unwrap_or(0.),
_ => 0.,
};
let individual_val = match input.as_non_exposed_value() {
Some(&TaggedValue::F64(x)) => vec![x; 4],
Some(TaggedValue::F64Array(values)) => values.clone(),
_ => vec![0.; 4],
let corner_values = match input.as_non_exposed_value() {
Some(TaggedValue::BoxCorners(corners)) => corners.to_corner_values(),
_ => [0.; 4],
};
let uniform_val = corner_values[0];
// Uniform/individual radio input widget
let uniform = RadioEntryData::new("Uniform")
@@ -2230,13 +2255,12 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
NodeGraphMessage::SetInputValue {
node_id,
input_index: CornerRadiusInput::INDEX,
value: Box::new(TaggedValue::F64(uniform_val)),
value: Box::new(TaggedValue::BoxCorners(BoxCorners::from(uniform_val))),
}
.into(),
]),
})
.on_commit(commit_value);
let individual_val_for_switch = individual_val.clone();
let individual = RadioEntryData::new("Individual")
.label("Individual")
.on_update(move |_| Message::Batched {
@@ -2250,7 +2274,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
NodeGraphMessage::SetInputValue {
node_id,
input_index: CornerRadiusInput::INDEX,
value: Box::new(TaggedValue::F64Array(individual_val_for_switch.clone())),
value: Box::new(TaggedValue::BoxCorners(BoxCorners::from(corner_values.to_vec()))),
}
.into(),
]),
@@ -2261,24 +2285,23 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
// Radius value input widget
let input_widget = if is_individual {
let from_string = |string: &str| {
string
.split(&[',', ' '])
.filter(|x| !x.is_empty())
.map(str::parse::<f64>)
.collect::<Result<Vec<f64>, _>>()
.ok()
.map(|values| TaggedValue::F64Array(values.into_iter().take(4).collect()))
};
TextInput::default()
.value(individual_val.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", "))
.on_update(optionally_update_value(move |x: &TextInput| from_string(&x.value), node_id, CornerRadiusInput::INDEX))
.value(corner_values.iter().map(|v| v.to_string()).collect::<Vec<_>>().join(", "))
.on_update(optionally_update_value(
move |x: &TextInput| Some(TaggedValue::BoxCorners(BoxCorners::from(x.value.as_str()))),
node_id,
CornerRadiusInput::INDEX,
))
.widget_instance()
} else {
NumberInput::default()
.value(Some(uniform_val))
.unit(" px")
.on_update(update_value(move |x: &NumberInput| TaggedValue::F64(x.value.unwrap()), node_id, CornerRadiusInput::INDEX))
.on_update(update_value(
move |x: &NumberInput| TaggedValue::BoxCorners(BoxCorners::from(x.value.unwrap())),
node_id,
CornerRadiusInput::INDEX,
))
.on_commit(commit_value)
.widget_instance()
};
@@ -2675,7 +2698,22 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
} else {
"Swap the start and end points of the gradient line."
})
.on_update(update_value(move |_| TaggedValue::OptionalDAffine2(Some(new_transform)), node_id, TransformInput::INDEX))
.on_update(move |_| Message::Batched {
messages: Box::new([
NodeGraphMessage::SetInputValue {
node_id,
input_index: HasTransformInput::INDEX,
value: Box::new(TaggedValue::Bool(true)),
}
.into(),
NodeGraphMessage::SetInputValue {
node_id,
input_index: TransformInput::INDEX,
value: Box::new(TaggedValue::DAffine2(new_transform)),
}
.into(),
]),
})
.widget_instance();
spread_methods_row.push(Separator::new(SeparatorStyle::Unrelated).widget_instance());
spread_methods_row.push(reverse_direction_button);
@@ -2719,8 +2757,8 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
_ => &StrokeJoin::Miter,
};
let has_dash_lengths = match &document_node.inputs[DashLengthsInput::INDEX].as_value() {
Some(TaggedValue::F64Array(values)) => values.is_empty(),
let has_dash_lengths = match &document_node.inputs[DashPatternInput::INDEX].as_value() {
Some(TaggedValue::DashPattern(pattern)) => pattern.0.is_empty(),
_ => true,
};
let miter_limit_disabled = join_value != &StrokeJoin::Miter;
@@ -2746,7 +2784,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
.for_socket(ParameterWidgetsInfo::new(node_id, PaintOrderInput::INDEX, true, context))
.property_row();
let disabled_number_input = NumberInput::default().unit(" px").disabled(has_dash_lengths);
let dash_lengths = array_of_number_widget(ParameterWidgetsInfo::new(node_id, DashLengthsInput::INDEX, true, context), TextInput::default().centered(true));
let dash_lengths = dash_pattern_widget(ParameterWidgetsInfo::new(node_id, DashPatternInput::INDEX, true, context), TextInput::default().centered(true));
let number_input = disabled_number_input;
let dash_offset = number_widget(ParameterWidgetsInfo::new(node_id, DashOffsetInput::INDEX, true, context), number_input);

View File

@@ -765,3 +765,46 @@ async fn none_fill_survives_document_reopen() {
let reopened_paint = fill_paint_value(editor.active_document());
assert!(reopened_paint.is_no_paint(), "a none fill should survive reopening, but the stored paint became {reopened_paint:?}");
}
#[tokio::test]
async fn legacy_four_input_fill_migrates_to_the_split_transform_shape() {
use graph_craft::document::value::TaggedValue;
use graphene_std::NodeInputDecleration as _;
// A minimal master-era document: a 4-input Fill (content, fill: wired, backup color, backup gradient) fed by another node
const LEGACY_DOCUMENT: &str = r#"{"network_interface":{"network":{"exports":[{"Node":{"node_id":1,"output_index":0,"lambda":false}}],"nodes":[[1,{"inputs":[{"Value":{"tagged_value":{"GraphicGroup":{"instance":[],"transform":[],"alpha_blending":[],"source_node_id":[]}},"exposed":true}},{"Node":{"node_id":2,"output_index":0,"lambda":false}},{"Value":{"tagged_value":{"OptionalColor":null},"exposed":false}},{"Value":{"tagged_value":{"Gradient":{"stops":[[0.0,{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}],[1.0,{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]],"gradient_type":"Linear","start":[0.0,0.5],"end":[1.0,0.5],"transform":[1.0,0.0,0.0,1.0,0.0,0.0]}},"exposed":false}}],"manual_composition":{"Concrete":{"name":"core::option::Option<alloc::sync::Arc<graphene_core::context::OwnedContextImpl>>","alias":null}},"implementation":{"ProtoNode":{"name":"graphene_core::vector::FillNode"}},"visible":true,"skip_deduplication":false}],[2,{"inputs":[{"Value":{"tagged_value":"None","exposed":false}},{"Value":{"tagged_value":{"GradientStops":[[0.0,{"red":0.0,"green":0.0,"blue":0.0,"alpha":1.0}],[1.0,{"red":1.0,"green":1.0,"blue":1.0,"alpha":1.0}]]},"exposed":false}},{"Value":{"tagged_value":{"F64":0.5},"exposed":false}}],"manual_composition":{"Concrete":{"name":"core::option::Option<alloc::sync::Arc<graphene_core::context::OwnedContextImpl>>","alias":null}},"implementation":{"ProtoNode":{"name":"graphene_core::ops::SampleGradientNode"}},"visible":true,"skip_deduplication":false}]],"scope_injections":[]},"network_metadata":{"persistent_metadata":{"node_metadata":[[1,{"persistent_metadata":{"reference":"Fill","display_name":"","input_properties":[{"input_data":{"input_name":"Vector Data"},"widget_override":null},{"input_data":{"input_name":"Fill"},"widget_override":null},{"input_data":{"input_name":"Backup Color"},"widget_override":null},{"input_data":{"input_name":"Backup Gradient"},"widget_override":null}],"output_names":["Future<Instances<VectorData>>"],"has_primary_output":true,"locked":false,"pinned":false,"node_type_metadata":{"Node":{"position":{"Absolute":[0,0]}}},"network_metadata":null}}],[2,{"persistent_metadata":{"reference":"Sample Gradient","display_name":"","input_properties":[{"input_data":{"input_name":"Primary"},"widget_override":null},{"input_data":{"input_name":"Gradient"},"widget_override":null},{"input_data":{"input_name":"Position"},"widget_override":null}],"output_names":["Future<Color>"],"has_primary_output":true,"locked":false,"pinned":false,"node_type_metadata":{"Node":{"position":{"Absolute":[-20,0]}}},"network_metadata":null}}]],"previewing":"No","navigation_metadata":{"node_graph_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"node_graph_to_viewport":[1.0,0.0,0.0,1.0,0.0,0.0],"node_graph_top_right":[0.0,0.0]},"selection_undo_history":[],"selection_redo_history":[]}}},"collapsed":[],"name":"legacy_fill.graphite","commit_hash":"0000000000000000000000000000000000000000","document_ptz":{"pan":[0.0,0.0],"tilt":0.0,"zoom":1.0,"flip":false},"document_mode":"DesignMode","view_mode":"Normal","overlays_visibility_settings":{"all":true,"artboard_name":true,"compass_rose":true,"quick_measurement":true,"transform_measurement":true,"transform_cage":true,"hover_outline":true,"selection_outline":true,"pivot":true,"path":true,"anchors":true,"handles":true},"rulers_visible":true,"snapping_state":{"snapping_enabled":true,"grid_snapping":false,"artboards":true,"tolerance":8.0,"bounding_box":{"center_point":true,"corner_point":true,"edge_midpoint":true,"align_with_edges":true,"distribute_evenly":true},"path":{"anchor_point":true,"line_midpoint":true,"along_path":true,"normal_to_path":true,"tangent_to_path":true,"path_intersection_point":true,"align_with_anchor_point":true,"perpendicular_from_endpoint":true},"grid":{"origin":[0.0,0.0],"grid_type":{"Rectangular":{"spacing":[1.0,1.0]}},"grid_color":{"red":0.6,"green":0.6,"blue":0.6,"alpha":1.0},"dot_display":false}},"graph_view_overlay_open":false,"graph_fade_artwork_percentage":80.0}"#;
// Deserializing alone must succeed, so a failure below is attributable to the migrations
DocumentMessageHandler::deserialize_document(LEGACY_DOCUMENT).expect("the legacy document should deserialize");
let mut editor = EditorTestUtils::create();
editor
.handle_message(PortfolioMessage::OpenDocumentFile {
document_name: None,
document_path: None,
document_serialized_content: LEGACY_DOCUMENT.to_string(),
})
.await;
let document = editor.active_document();
let (network_path, node_id) = find_fill_node(document);
let network = document.network_interface.nested_network(&network_path).expect("the found network path should resolve");
let inputs = &network.nodes[&node_id].inputs;
assert_eq!(inputs.len(), 8, "the legacy Fill should upgrade to the 8-input shape");
let paint = &inputs[graphene_std::vector::fill::FillInput::INDEX];
assert!(
matches!(paint, graph_craft::document::NodeInput::Node { .. }),
"the wired legacy fill should keep its connection, but became {paint:?}"
);
let has_transform = inputs[graphene_std::vector::fill::HasTransformInput::INDEX].as_value();
assert!(
matches!(has_transform, Some(TaggedValue::Bool(_))),
"the has-transform input should hold a bool, but became {has_transform:?}"
);
let transform = inputs[graphene_std::vector::fill::TransformInput::INDEX].as_value();
assert!(
matches!(transform, Some(TaggedValue::DAffine2(_))),
"the transform input should hold a matrix, but became {transform:?}"
);
}

View File

@@ -17,7 +17,8 @@ use graphene_std::text::{TextAlign, TypesettingConfig};
use graphene_std::transform::ScaleType;
use graphene_std::uuid::NodeId;
use graphene_std::vector::graphic_types;
use graphene_std::vector::style::{PaintOrder, StrokeAlign};
use graphene_std::vector::misc::BoxCorners;
use graphene_std::vector::style::{DashPattern, PaintOrder, StrokeAlign};
use std::collections::HashMap;
use std::f64::consts::PI;
use std::ops::Range;
@@ -1376,6 +1377,29 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
}
}
/// Converts a legacy stroke dash input (a `List<f64>`, single `f64`, or comma/space separated `String`) to the `DashPattern` value type.
fn migrate_dash_input(input: &NodeInput) -> Option<NodeInput> {
let NodeInput::Value { tagged_value, exposed } = input else { return None };
let pattern = match &*tagged_value.clone().into_inner() {
TaggedValue::F64Array(lengths) => DashPattern::from(lengths.clone()),
TaggedValue::F64(length) => DashPattern::from(*length),
TaggedValue::String(text) => DashPattern::from(text.as_str()),
_ => return None,
};
Some(NodeInput::value(TaggedValue::DashPattern(pattern), *exposed))
}
/// Converts a legacy rectangle corner radius input (a single `f64` or a `List<f64>` of up to four values) to the `BoxCorners` value type.
fn migrate_corner_radius_input(input: &NodeInput) -> Option<NodeInput> {
let NodeInput::Value { tagged_value, exposed } = input else { return None };
let corners = match &*tagged_value.clone().into_inner() {
TaggedValue::F64Array(values) => BoxCorners::from(values.clone()),
TaggedValue::F64(value) => BoxCorners::from(*value),
_ => return None,
};
Some(NodeInput::value(TaggedValue::BoxCorners(corners), *exposed))
}
fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], document: &mut DocumentMessageHandler, reset_node_definitions_on_open: bool) -> Option<()> {
// Must run before the reset block below: a node referencing a removed catalog entry would otherwise abort
// `migrate_node` via the `?` on `resolve_document_node_type`, preventing subsequent migration blocks from running.
@@ -1640,7 +1664,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
.network_interface
.set_input(&InputConnector::node(*node_id, 1), NodeInput::value(fill_value, exposed), network_path);
// Gradient metadata (4, 5, 6): applies only to a literal gradient, solids/none keep the template defaults
// Gradient metadata (4, 5, 6, 7): applies only to a literal gradient, solids/none keep the template defaults
if let graphic_types::migrations::legacy::LegacyFill::Gradient(gradient) = old_fill {
document.network_interface.set_input(
&InputConnector::node(*node_id, 4),
@@ -1653,16 +1677,19 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
network_path,
);
let transform = if gradient.absolute {
Some(gradient.transform * gradient.to_transform())
if gradient.absolute {
let transform = gradient.transform * gradient.to_transform();
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
document
.network_interface
.set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
} else {
// Baking a legacy bounding-box-relative gradient is deferred until the measurement pre-pass can supply the paint target's bounds
// Baking a legacy bounding-box-relative gradient is deferred until the measurement pre-pass can supply the paint
// target's bounds, so the template's unbaked `_has_transform = false` stands until the bake lands
document.pending_gradient_bbox_bake.push((network_path.to_vec(), *node_id, gradient.clone()));
None
};
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::OptionalDAffine2(transform), false), network_path);
}
}
}
// Wired/exposed fill keeps the connection.
@@ -1697,19 +1724,54 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
network_path,
);
let transform = if g.absolute {
Some(g.transform * g.to_transform())
if g.absolute {
let transform = g.transform * g.to_transform();
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
document
.network_interface
.set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
} else {
document.pending_gradient_bbox_bake.push((network_path.to_vec(), *node_id, g.clone()));
None
};
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::OptionalDAffine2(transform), false), network_path);
}
}
}
inputs_count = 7;
inputs_count = 8;
}
// Fill split its `Option<DAffine2>` placement into a `_has_transform` bool immediately before the `_transform` matrix
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::fill::IDENTIFIER) && inputs_count == 7 {
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)?;
for (index, input) in old_inputs.iter().enumerate().take(6) {
document.network_interface.set_input(&InputConnector::node(*node_id, index), input.clone(), network_path);
}
match old_inputs.get(6).and_then(|input| input.as_value()) {
Some(TaggedValue::LegacyOptionalDAffine2(value)) => {
let has_transform = value.is_some();
let transform = value.unwrap_or(glam::DAffine2::IDENTITY);
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(has_transform), false), network_path);
document
.network_interface
.set_input(&InputConnector::node(*node_id, 7), NodeInput::value(TaggedValue::DAffine2(transform), false), network_path);
}
// A wired (or otherwise non-value) transform keeps its connection and is treated as present
_ => {
document
.network_interface
.set_input(&InputConnector::node(*node_id, 6), NodeInput::value(TaggedValue::Bool(true), false), network_path);
let transform_input = old_inputs.get(6).cloned().unwrap_or_else(|| NodeInput::value(TaggedValue::DAffine2(glam::DAffine2::IDENTITY), false));
document.network_interface.set_input(&InputConnector::node(*node_id, 7), transform_input, network_path);
}
}
inputs_count = 8;
}
// Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644)
@@ -1728,7 +1790,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node(*node_id, 5), old_inputs[6].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 6), old_inputs[7].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 7), paint_order_input, network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 8), old_inputs[3].clone(), 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(*node_id, 8), dash_input, network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 9), old_inputs[4].clone(), network_path);
}
@@ -1783,6 +1846,36 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
}
// The stroke dash sequence became the `DashPattern` value type; convert any already-shaped stroke that still stores a legacy dash input
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::stroke::IDENTIFIER)
&& let Some(dash_input) = node.inputs.get(graphene_std::vector::stroke::DashPatternInput::INDEX)
&& let Some(migrated) = migrate_dash_input(dash_input)
{
document
.network_interface
.set_input(&InputConnector::node(*node_id, graphene_std::vector::stroke::DashPatternInput::INDEX), migrated, network_path);
}
// The rectangle's corner radius became the `BoxCorners` value type and its hidden individual-radii toggle moved after the
// user-visible inputs. A legacy rectangle stores that toggle (a plain `bool`) at index 3, where the new shape stores the corner
// radius, so a `bool` value there identifies the old input order: [width, height, individual, corner_radius, clamped].
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER)
&& let Some(toggle_input) = node.inputs.get(3)
&& matches!(toggle_input.as_value(), Some(TaggedValue::Bool(_)))
{
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 corner_radius = migrate_corner_radius_input(&old_inputs[4]).unwrap_or_else(|| old_inputs[4].clone());
document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 3), corner_radius, network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 4), old_inputs[5].clone(), network_path);
document.network_interface.set_input(&InputConnector::node(*node_id, 5), old_inputs[3].clone(), network_path);
}
// Upgrade Text node to include line height and character spacing, which were previously hardcoded to 1, from https://github.com/GraphiteEditor/Graphite/pull/2016
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_std::text::TextNode")) && inputs_count == 8 {
let mut template: NodeTemplate = legacy_text_node_template()?;

View File

@@ -569,8 +569,8 @@ pub fn get_stroke_options(layer: LayerNodeIdentifier, network_interface: &NodeNe
Some(TaggedValue::PaintOrder(value)) => *value,
_ => PaintOrder::default(),
};
let dash_lengths = match read(graphene_std::vector::stroke::DashLengthsInput::INDEX) {
Some(TaggedValue::F64Array(value)) => value.clone(),
let dash_lengths = match read(graphene_std::vector::stroke::DashPatternInput::INDEX) {
Some(TaggedValue::DashPattern(value)) => value.0.iter_element_values().copied().collect(),
_ => Vec::new(),
};
let dash_offset = match read(graphene_std::vector::stroke::DashOffsetInput::INDEX) {
@@ -648,9 +648,11 @@ pub fn read_fill_node_gradient(fill_node: &DocumentNode, bounding_box: impl FnOn
Some(&TaggedValue::GradientSpreadMethod(value)) => value,
_ => GradientSpreadMethod::default(),
};
let has_transform = matches!(fill_node.inputs.get(fill::HasTransformInput::INDEX).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true)));
let transform_input = fill_node.inputs.get(fill::TransformInput::INDEX).and_then(|input| input.as_value());
let transform = match transform_input {
Some(&TaggedValue::OptionalDAffine2(value)) => value.unwrap_or_else(|| initial_gradient_transform_for_bounding_box(bounding_box())),
let transform = match (has_transform, transform_input) {
(true, Some(&TaggedValue::DAffine2(value))) => value,
(false, _) => initial_gradient_transform_for_bounding_box(bounding_box()),
_ => DAffine2::IDENTITY,
};
@@ -803,10 +805,10 @@ pub fn set_fill_for_selected_layers(fill_choice: FillChoice, document: &Document
Some(TaggedValue::GradientSpreadMethod(value)) => *value,
_ => GradientSpreadMethod::default(),
};
let transform = match read(graphene_std::vector::fill::TransformInput::INDEX) {
Some(TaggedValue::OptionalDAffine2(value)) => {
value.unwrap_or_else(|| initial_gradient_transform_for_bounding_box(document.network_interface.document_metadata().nonzero_bounding_box(layer)))
}
let has_transform = matches!(read(graphene_std::vector::fill::HasTransformInput::INDEX), Some(TaggedValue::Bool(true)));
let transform = match (has_transform, read(graphene_std::vector::fill::TransformInput::INDEX)) {
(true, Some(TaggedValue::DAffine2(value))) => *value,
(false, _) => initial_gradient_transform_for_bounding_box(document.network_interface.document_metadata().nonzero_bounding_box(layer)),
_ => DAffine2::IDENTITY,
};

View File

@@ -214,7 +214,7 @@ pub fn apply_paint_order(drawing: &mut DrawingToolState, order: PaintOrder, docu
pub fn apply_dash_lengths(drawing: &mut DrawingToolState, lengths: Vec<f64>, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {
drawing.dash_lengths = Some(lengths.clone());
set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashLengthsInput::INDEX, TaggedValue::F64Array(lengths), responses);
set_stroke_input_for_selected(document, graphene_std::vector::stroke::DashPatternInput::INDEX, TaggedValue::DashPattern(lengths.into()), responses);
}
pub fn apply_dash_offset(drawing: &mut DrawingToolState, offset: f64, document: &DocumentMessageHandler, responses: &mut VecDeque<Message>) {

View File

@@ -2067,8 +2067,9 @@ mod test_gradient {
_ => GradientSpreadMethod::default(),
};
let has_transform = matches!(fill_node.inputs.get(fill::HasTransformInput::INDEX).and_then(|input| input.as_value()), Some(&TaggedValue::Bool(true)));
let local_transform = match fill_node.inputs.get(fill::TransformInput::INDEX).and_then(|input| input.as_value()) {
Some(&TaggedValue::OptionalDAffine2(Some(value))) => value,
Some(&TaggedValue::DAffine2(value)) if has_transform => value,
_ => DAffine2::IDENTITY,
};

View File

@@ -625,10 +625,14 @@ impl NodeGraphExecutor {
if fill_transform_unbaked(document, &network_path, fill_node_id) {
let absolute_gradient = gradient.to_absolute(bounding_box, item_transform);
let gradient_transform = absolute_gradient.transform * absolute_gradient.to_transform();
let input = InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX);
let has_transform_input = InputConnector::node(fill_node_id, graphene_std::vector::fill::HasTransformInput::INDEX);
let transform_input = InputConnector::node(fill_node_id, graphene_std::vector::fill::TransformInput::INDEX);
document
.network_interface
.set_input(&input, NodeInput::value(TaggedValue::OptionalDAffine2(Some(gradient_transform)), false), &network_path);
.set_input(&has_transform_input, NodeInput::value(TaggedValue::Bool(true), false), &network_path);
document
.network_interface
.set_input(&transform_input, NodeInput::value(TaggedValue::DAffine2(gradient_transform), false), &network_path);
}
// The transform is settled, so its entry no longer needs to persist for a retry on the next open
@@ -840,8 +844,8 @@ fn fill_transform_unbaked(document: &DocumentMessageHandler, network_path: &[Nod
};
let Some(node) = network.nodes.get(&fill_node_id) else { return false };
matches!(
node.inputs.get(graphene_std::vector::fill::TransformInput::INDEX).and_then(|input| input.as_value()),
Some(TaggedValue::OptionalDAffine2(None))
node.inputs.get(graphene_std::vector::fill::HasTransformInput::INDEX).and_then(|input| input.as_value()),
Some(TaggedValue::Bool(false))
)
}

View File

@@ -16,7 +16,10 @@ use dyn_any::DynAny;
pub use dyn_any::StaticType;
pub use glam::{DAffine2, DVec2, IVec2, UVec2};
use graphene_application_io::resource::ResourceHash;
use graphene_application_io::resource::ResourceId;
use graphic_types::raster_types::{CPU, Image, Raster};
use graphic_types::vector_types::vector::misc::BoxCorners;
use graphic_types::vector_types::vector::style::DashPattern;
use graphic_types::vector_types::vector::style::Gradient;
use graphic_types::vector_types::vector::{self, ReferencePoint};
use graphic_types::{Artboard, Graphic, Vector};
@@ -533,19 +536,21 @@ tagged_value! {
DVec2(DVec2),
#[serde(alias = "Affine2")]
DAffine2(DAffine2),
OptionalDAffine2(Option<DAffine2>),
#[serde(alias = "FillGradient")]
LegacyGradient(graphic_types::migrations::legacy::LegacyGradient),
Font(Font),
Footprint(Footprint),
VectorModification(Box<VectorModification>),
ImageData(Image<Color>),
Resource(graphene_application_io::resource::ResourceId),
Resource(ResourceId),
// Legacy
#[serde(alias = "OptionalDAffine2")]
LegacyOptionalDAffine2(Option<DAffine2>),
#[serde(alias = "FillGradient")]
LegacyGradient(graphic_types::migrations::legacy::LegacyGradient),
#[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill),
// ==========
// ENUM TYPES
// ==========
#[serde(alias = "Fill")]
LegacyFill(graphic_types::migrations::legacy::LegacyFill),
BlendMode(core_types::blending::BlendMode),
LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation),
QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel),
@@ -575,6 +580,8 @@ tagged_value! {
StrokeJoin(vector::style::StrokeJoin),
StrokeAlign(vector::style::StrokeAlign),
PaintOrder(vector::style::PaintOrder),
DashPattern(vector::style::DashPattern),
BoxCorners(vector::misc::BoxCorners),
GradientType(vector::style::GradientType),
GradientSpreadMethod(vector::style::GradientSpreadMethod),
ReferencePoint(vector::ReferencePoint),
@@ -723,6 +730,8 @@ impl TaggedValue {
() if ty == TypeId::of::<List<Graphic>>() => to_color(string).map(TaggedValue::Color)?,
() if ty == TypeId::of::<List<Gradient>>() => to_gradient(string).map(TaggedValue::Gradient)?,
() if ty == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?,
() if ty == TypeId::of::<DashPattern>() => TaggedValue::DashPattern(DashPattern::from(string)),
() if ty == TypeId::of::<BoxCorners>() => TaggedValue::BoxCorners(BoxCorners::from(string)),
_ => return None,
};
Some(ty)

View File

@@ -2,6 +2,7 @@ use super::PointId;
use super::algorithms::offset_subpath::MAX_ABSOLUTE_DIFFERENCE;
use crate::subpath::{BezierHandles, ManipulatorGroup};
use crate::vector::{SegmentId, Vector};
use core_types::list::{Item, List};
use dyn_any::DynAny;
use glam::DVec2;
use kurbo::{BezPath, CubicBez, Line, ParamCurve, ParamCurveDeriv, PathSeg, Point, QuadBez};
@@ -49,6 +50,77 @@ pub enum RowsOrColumns {
Columns,
}
/// A box's four corner values, such as a rectangle's corner radii, expanded on read from any number of stored
/// values by the CSS `border-radius` shorthand rules.
///
/// Wraps a `List<f64>` so the Data panel can introspect its values, mirroring how `DashPattern` wraps its lengths,
/// while remaining a single rank-0 value on the wire.
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
pub struct BoxCorners(pub List<f64>);
impl BoxCorners {
/// Expands the stored values to the four corners, clockwise from the top-left, by the CSS `border-radius` shorthand rules.
/// - `[]` → `[0, 0, 0, 0]`
/// - `[a]` → `[a, a, a, a]`
/// - `[a, b]` → `[a, b, a, b]`
/// - `[a, b, c]` → `[a, b, c, b]`
/// - `[a, b, c, d, …]` → `[a, b, c, d]`
pub fn to_corner_values(&self) -> [f64; 4] {
let values: Vec<f64> = self.0.iter_element_values().copied().collect();
match values.as_slice() {
[] => [0., 0., 0., 0.],
&[a] => [a, a, a, a],
&[a, b] => [a, b, a, b],
&[a, b, c] => [a, b, c, b],
&[a, b, c, d, ..] => [a, b, c, d],
}
}
}
// `List<f64>` is a runtime-only wire type, so serialize the corners as their bare values to keep documents stable
#[cfg(feature = "serde")]
impl serde::Serialize for BoxCorners {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.0.iter_element_values())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for BoxCorners {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self::from(<Vec<f64> as serde::Deserialize>::deserialize(deserializer)?))
}
}
impl From<f64> for BoxCorners {
fn from(value: f64) -> Self {
Self(List::new_from_element(value))
}
}
impl From<Vec<f64>> for BoxCorners {
fn from(values: Vec<f64>) -> Self {
Self(values.into_iter().map(Item::new_from_element).collect())
}
}
impl From<&str> for BoxCorners {
fn from(text: &str) -> Self {
Self::from(
text.split([',', ' '])
.filter(|piece| !piece.is_empty())
.filter_map(|piece| piece.parse::<f64>().ok())
.collect::<Vec<f64>>(),
)
}
}
impl From<String> for BoxCorners {
fn from(text: String) -> Self {
Self::from(text.as_str())
}
}
pub trait AsU64 {
fn as_u64(&self) -> u64;
}

View File

@@ -3,12 +3,14 @@
pub use crate::gradient::*;
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
use core_types::transform::Transform;
use dyn_any::DynAny;
use glam::DAffine2;
use std::f64::consts::{PI, TAU};
/// Describes an editable fill choice, storing color or gradient stops without gradient placement metadata.
/// The editor's in-memory paint picker state, storing color or gradient stops without gradient placement metadata.
/// Not stored in documents: paint inputs hold the picked value as a plain color, gradient, or no-paint type default.
///
/// Can be None, a solid [Color], or a linear/radial [Gradient].
///
@@ -201,6 +203,65 @@ fn daffine2_identity() -> DAffine2 {
DAffine2::IDENTITY
}
/// A stroke's dash pattern: a sequence of lengths that alternate dash, gap, dash, gap, and so on. An odd-length
/// sequence repeats with the dash and gap roles swapped.
///
/// Wraps a `List<f64>` so the Data panel can introspect its lengths, mirroring how `Artboard` wraps a `List<Graphic>`,
/// while remaining a single rank-0 value on the wire.
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
pub struct DashPattern(pub List<f64>);
impl DashPattern {
/// Returns the dash lengths with any negative values clamped to zero.
pub fn clamped_lengths(&self) -> Vec<f64> {
self.0.iter_element_values().map(|length| length.max(0.)).collect()
}
}
// `List<f64>` is a runtime-only wire type, so serialize the pattern as its bare lengths to keep documents stable
#[cfg(feature = "serde")]
impl serde::Serialize for DashPattern {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_seq(self.0.iter_element_values())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for DashPattern {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
Ok(Self::from(<Vec<f64> as serde::Deserialize>::deserialize(deserializer)?))
}
}
impl From<f64> for DashPattern {
fn from(length: f64) -> Self {
Self(List::new_from_element(length))
}
}
impl From<Vec<f64>> for DashPattern {
fn from(lengths: Vec<f64>) -> Self {
Self(lengths.into_iter().map(Item::new_from_element).collect())
}
}
impl From<&str> for DashPattern {
fn from(text: &str) -> Self {
Self::from(
text.split([',', ' '])
.filter(|piece| !piece.is_empty())
.filter_map(|piece| piece.parse::<f64>().ok())
.collect::<Vec<f64>>(),
)
}
}
impl From<String> for DashPattern {
fn from(text: String) -> Self {
Self::from(text.as_str())
}
}
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]

View File

@@ -4,44 +4,10 @@ use dyn_any::DynAny;
use glam::DVec2;
use graphic_types::Vector;
use vector_types::subpath;
use vector_types::vector::misc::{ArcType, AsU64, GridType};
use vector_types::vector::misc::{ArcType, AsU64, BoxCorners, GridType};
use vector_types::vector::misc::{HandleId, SpiralType};
use vector_types::vector::{PointId, SegmentId, StrokeId};
/// Expands the corner-radius lanes to four corners using the CSS
/// `border-radius` shorthand rules, then builds the rounded rectangle.
/// - `[a]` (also a plain scalar radius) expands to `[a, a, a, a]`
/// - `[a, b]` expands to `[a, b, a, b]`
/// - `[a, b, c]` expands to `[a, b, c, b]`
/// - `[a, b, c, d, …]` truncates to `[a, b, c, d]`
/// - `[]` expands to `[0, 0, 0, 0]`
fn rounded_rectangle(values: &[f64], size: DVec2, clamped: bool) -> Vector {
let radii: [f64; 4] = match values {
[] => [0., 0., 0., 0.],
&[a] => [a, a, a, a],
&[a, b] => [a, b, a, b],
&[a, b, c] => [a, b, c, b],
&[a, b, c, d, ..] => [a, b, c, d],
};
let clamped_radius = if clamped {
// Algorithm follows the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
let mut scale_factor: f64 = 1.;
for i in 0..4 {
let side_length = if i % 2 == 0 { size.x } else { size.y };
let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4];
if side_length < adjacent_corner_radius_sum {
scale_factor = scale_factor.min(side_length / adjacent_corner_radius_sum);
}
}
radii.map(|x| x * scale_factor)
} else {
radii
};
Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., clamped_radius))
}
/// Generates a circle shape with a chosen radius.
#[node_macro::node(category("Vector: Shape"))]
fn circle(
@@ -143,12 +109,43 @@ fn rectangle(
#[unit(" px")]
#[default(100)]
height: f64,
_individual_corner_radii: bool, // TODO: Move this to the bottom once we have a migration capability
corner_radius: IList<f64>,
corner_radius: BoxCorners,
#[default(true)] clamped: bool,
_individual_corner_radii: bool,
) -> Vector {
let values: Vec<f64> = (0..corner_radius.len()).map(|index| corner_radius.get(index)).collect();
rounded_rectangle(&values, DVec2::new(width, height), clamped)
let size = DVec2::new(width, height);
let radii = corner_radius.to_corner_values();
// Scale down overlapping adjacent radii to fit, following the CSS spec: <https://drafts.csswg.org/css-backgrounds/#corner-overlap>
let radii = if clamped {
let radii = radii.map(|radius| radius.max(0.));
let mut scale_factor: f64 = 1.;
for i in 0..4 {
let side_length = if i % 2 == 0 { size.x } else { size.y };
let adjacent_corner_radius_sum = radii[i] + radii[(i + 1) % 4];
if side_length < adjacent_corner_radius_sum {
scale_factor = scale_factor.min((side_length / adjacent_corner_radius_sum).max(0.));
}
}
radii.map(|radius| radius * scale_factor)
} else {
radii
};
Vector::from_subpath(subpath::Subpath::new_rounded_rectangle(size / -2., size / 2., radii))
}
/// Builds a set of four corner values, such as a rectangle's corner radii, from a list of one, two, three, or four values.
#[node_macro::node(category("Vector: Shape"))]
fn box_corners(
_: impl Ctx,
/// The corner values, filling the four corners clockwise from the top-left. Give one value for all corners, two for opposite pairs, three for top-left, the two sides, then bottom-right, or four for each corner.
values: IList<f64>,
) -> BoxCorners {
let values: Vec<f64> = (0..values.len()).map(|index| values.get(index)).collect();
BoxCorners::from(values)
}
/// Generates an regular polygon shape like a triangle, square, pentagon, hexagon, heptagon, octagon, or any higher n-gon.

View File

@@ -37,7 +37,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::{Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::style::{DashPattern, Gradient, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
use vector_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD};
use vector_types::{GradientSpreadMethod, GradientType};
@@ -339,10 +339,11 @@ fn fill<'e>(
_backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_transform: Option<DAffine2>,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Vector, Attr<'e, Fill>), Interrupt> {
let mut paint = paint_table(fill);
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _transform);
default_gradient_paint(&mut paint, element.bounding_box(), _gradient_type, _spread_method, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
}
@@ -359,14 +360,15 @@ fn fill_graphic_leveled<'e>(
_backup_gradient: IList<Gradient>,
_gradient_type: GradientType,
_spread_method: GradientSpreadMethod,
_transform: Option<DAffine2>,
_has_transform: bool,
_transform: DAffine2,
) -> Result<(Graphic<'static>, Attr<'e, Fill>), 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_type, _spread_method, _transform);
default_gradient_paint(&mut paint, bounds, _gradient_type, _spread_method, _has_transform.then_some(_transform));
let parked = park_paint(ctx.arena(), paint)?;
Ok((element, Attr(Some(parked))))
}
@@ -395,13 +397,13 @@ fn stroke<'e>(
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 lengths. 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_lengths: IList<f64>,
/// 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 = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect();
let dash_lengths = dash_pattern.clamped_lengths();
let mut stroke = Stroke {
weight,
dash_lengths,
@@ -456,10 +458,10 @@ fn stroke_graphic_leveled<'e>(
join: StrokeJoin,
#[default(4.)] miter_limit: f64,
paint_order: PaintOrder,
dash_lengths: IList<f64>,
dash_pattern: DashPattern,
#[unit(" px")] dash_offset: f64,
) -> Result<(Graphic<'static>, Attr<TransformAttr>, Attr<'e, StrokeAttr>), Interrupt> {
let dash_lengths = (0..dash_lengths.len()).map(|index| dash_lengths.get(index).max(0.)).collect();
let dash_lengths = dash_pattern.clamped_lengths();
let stroke = Stroke {
weight,
dash_lengths,