Implement clipping masks, stroke align, and stroke paint order (#2644)

* refactor: opacity + blend_mode -> blend_style

* Add code for clipping

* Add alt-click masking

* Clip to all colors. Fill option

* Fix undo not working. Fix strokes not being white

* Allow clipped to be grouped or raster

* Switch to alpha mode in mask-type

* add plumbing to know if clipped in frontend and add fill slider

* Attempt at document upgrade code

* Fix fill slider

* Add clipped styling and Alt-click layer border

* Use mask attr judiciously by using clip when possible

* Fix breaking documents and upgrade code

* Fix fixes

* No-op toggle if last child of parent and don't show clip UI if last element

* Fix mouse styles by plumbing clippable to frontend

* Fix Clip detection by disallowed groups as clipPath according to SVG spec doesn't allow <g>

* Add opacity to clippers can_use_clip check

* Fix issue with clipping not working nicely with strokes by using masks

* Add vello code

* cleanup

* Add stroke alignment hacks to SVG renderer

* svg: Fix mask bounds in vector data

* vello: Implement mask hacks to support stroke alignment

* Move around alignment and doc upgrade code

* rename Line X -> X

* An attempt at fixing names not updating

* svg: add stroke order with svg

* vello: add stroke order with by calling one before the other explicitly

* fix merge

* fix svg renderer messing up transform det

* Code review; reorder and rename parameters (TODO: fix tools)

* Fixes to previous

* Formatting

* fix bug 3

* some moving around (not fixed)

* fix issue 1

* fix vello

* Final code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
mTvare 2025-06-18 11:06:37 +05:30 committed by Keavon Chambers
parent 8a3f133140
commit e238753a35
28 changed files with 1025 additions and 311 deletions

View File

@ -187,7 +187,8 @@ impl PreferencesDialogMessageHandler {
]; ];
let mut checkbox_id = CheckboxId::default(); let mut checkbox_id = CheckboxId::default();
let vector_mesh_tooltip = "Allow tools to produce vector meshes, where more than two segments can connect to an anchor point.\n\nCurrently this does not properly handle line joins and fills."; let vector_mesh_tooltip =
"Allow tools to produce vector meshes, where more than two segments can connect to an anchor point.\n\nCurrently this does not properly handle stroke joins and fills.";
let vector_meshes = vec![ let vector_meshes = vec![
Separator::new(SeparatorType::Unrelated).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(),
Separator::new(SeparatorType::Unrelated).widget_holder(), Separator::new(SeparatorType::Unrelated).widget_holder(),

View File

@ -121,6 +121,9 @@ pub enum DocumentMessage {
SelectedLayersReorder { SelectedLayersReorder {
relative_index_offset: isize, relative_index_offset: isize,
}, },
ClipLayer {
id: NodeId,
},
SelectLayer { SelectLayer {
id: NodeId, id: NodeId,
ctrl: bool, ctrl: bool,
@ -142,6 +145,9 @@ pub enum DocumentMessage {
SetOpacityForSelectedLayers { SetOpacityForSelectedLayers {
opacity: f64, opacity: f64,
}, },
SetFillForSelectedLayers {
fill: f64,
},
SetOverlaysVisibility { SetOverlaysVisibility {
visible: bool, visible: bool,
overlays_type: Option<OverlaysType>, overlays_type: Option<OverlaysType>,

View File

@ -20,7 +20,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{Flo
use crate::messages::portfolio::document::utility_types::nodes::RawBuffer; use crate::messages::portfolio::document::utility_types::nodes::RawBuffer;
use crate::messages::portfolio::utility_types::PersistentData; use crate::messages::portfolio::utility_types::PersistentData;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_opacity}; use crate::messages::tool::common_functionality::graph_modification_utils::{self, get_blend_mode, get_fill, get_opacity};
use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys; use crate::messages::tool::tool_messages::select_tool::SelectToolPointerKeys;
use crate::messages::tool::tool_messages::tool_prelude::Key; use crate::messages::tool::tool_messages::tool_prelude::Key;
use crate::messages::tool::utility_types::ToolType; use crate::messages::tool::utility_types::ToolType;
@ -1083,6 +1083,12 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
DocumentMessage::SelectedLayersReorder { relative_index_offset } => { DocumentMessage::SelectedLayersReorder { relative_index_offset } => {
self.selected_layers_reorder(relative_index_offset, responses); self.selected_layers_reorder(relative_index_offset, responses);
} }
DocumentMessage::ClipLayer { id } => {
let layer = LayerNodeIdentifier::new(id, &self.network_interface, &[]);
responses.add(DocumentMessage::AddTransaction);
responses.add(GraphOperationMessage::ClipModeToggle { layer });
}
DocumentMessage::SelectLayer { id, ctrl, shift } => { DocumentMessage::SelectLayer { id, ctrl, shift } => {
let layer = LayerNodeIdentifier::new(id, &self.network_interface, &[]); let layer = LayerNodeIdentifier::new(id, &self.network_interface, &[]);
@ -1177,6 +1183,12 @@ impl MessageHandler<DocumentMessage, DocumentMessageData<'_>> for DocumentMessag
responses.add(GraphOperationMessage::OpacitySet { layer, opacity }); responses.add(GraphOperationMessage::OpacitySet { layer, opacity });
} }
} }
DocumentMessage::SetFillForSelectedLayers { fill } => {
let fill = fill.clamp(0., 1.);
for layer in self.network_interface.selected_nodes().selected_layers_except_artboards(&self.network_interface) {
responses.add(GraphOperationMessage::BlendingFillSet { layer, fill });
}
}
DocumentMessage::SetOverlaysVisibility { visible, overlays_type } => { DocumentMessage::SetOverlaysVisibility { visible, overlays_type } => {
let visibility_settings = &mut self.overlays_visibility_settings; let visibility_settings = &mut self.overlays_visibility_settings;
let overlays_type = match overlays_type { let overlays_type = match overlays_type {
@ -2533,38 +2545,47 @@ impl DocumentMessageHandler {
let selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&self.network_interface); let selected_layers_except_artboards = selected_nodes.selected_layers_except_artboards(&self.network_interface);
// Look up the current opacity and blend mode of the selected layers (if any), and split the iterator into the first tuple and the rest. // Look up the current opacity and blend mode of the selected layers (if any), and split the iterator into the first tuple and the rest.
let mut opacity_and_blend_mode = selected_layers_except_artboards.map(|layer| { let mut blending_options = selected_layers_except_artboards.map(|layer| {
( (
get_opacity(layer, &self.network_interface).unwrap_or(100.), get_opacity(layer, &self.network_interface).unwrap_or(100.),
get_fill(layer, &self.network_interface).unwrap_or(100.),
get_blend_mode(layer, &self.network_interface).unwrap_or_default(), get_blend_mode(layer, &self.network_interface).unwrap_or_default(),
) )
}); });
let first_opacity_and_blend_mode = opacity_and_blend_mode.next(); let first_blending_options = blending_options.next();
let result_opacity_and_blend_mode = opacity_and_blend_mode; let result_blending_options = blending_options;
// If there are no selected layers, disable the opacity and blend mode widgets. // If there are no selected layers, disable the opacity and blend mode widgets.
let disabled = first_opacity_and_blend_mode.is_none(); let disabled = first_blending_options.is_none();
// Amongst the selected layers, check if the opacities and blend modes are identical across all layers. // Amongst the selected layers, check if the opacities and blend modes are identical across all layers.
// The result is setting `option` and `blend_mode` to Some value if all their values are identical, or None if they are not. // The result is setting `option` and `blend_mode` to Some value if all their values are identical, or None if they are not.
// If identical, we display the value in the widget. If not, we display a dash indicating dissimilarity. // If identical, we display the value in the widget. If not, we display a dash indicating dissimilarity.
let (opacity, blend_mode) = first_opacity_and_blend_mode let (opacity, fill, blend_mode) = first_blending_options
.map(|(first_opacity, first_blend_mode)| { .map(|(first_opacity, first_fill, first_blend_mode)| {
let mut opacity_identical = true; let mut opacity_identical = true;
let mut fill_identical = true;
let mut blend_mode_identical = true; let mut blend_mode_identical = true;
for (opacity, blend_mode) in result_opacity_and_blend_mode { for (opacity, fill, blend_mode) in result_blending_options {
if (opacity - first_opacity).abs() > (f64::EPSILON * 100.) { if (opacity - first_opacity).abs() > (f64::EPSILON * 100.) {
opacity_identical = false; opacity_identical = false;
} }
if (fill - first_fill).abs() > (f64::EPSILON * 100.) {
fill_identical = false;
}
if blend_mode != first_blend_mode { if blend_mode != first_blend_mode {
blend_mode_identical = false; blend_mode_identical = false;
} }
} }
(opacity_identical.then_some(first_opacity), blend_mode_identical.then_some(first_blend_mode)) (
opacity_identical.then_some(first_opacity),
fill_identical.then_some(first_fill),
blend_mode_identical.then_some(first_blend_mode),
)
}) })
.unwrap_or((None, None)); .unwrap_or((None, None, None));
let blend_mode_menu_entries = BlendMode::list_svg_subset() let blend_mode_menu_entries = BlendMode::list_svg_subset()
.iter() .iter()
@ -2623,6 +2644,28 @@ impl DocumentMessageHandler {
.max_width(100) .max_width(100)
.tooltip("Opacity") .tooltip("Opacity")
.widget_holder(), .widget_holder(),
Separator::new(SeparatorType::Related).widget_holder(),
NumberInput::new(fill)
.label("Fill")
.unit("%")
.display_decimal_places(0)
.disabled(disabled)
.min(0.)
.max(100.)
.range_min(Some(0.))
.range_max(Some(100.))
.mode_range()
.on_update(|number_input: &NumberInput| {
if let Some(value) = number_input.value {
DocumentMessage::SetFillForSelectedLayers { fill: value / 100. }.into()
} else {
Message::NoOp
}
})
.on_commit(|_| DocumentMessage::AddTransaction.into())
.max_width(100)
.tooltip("Fill")
.widget_holder(),
]; ];
let layers_panel_control_bar_left = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]); let layers_panel_control_bar_left = WidgetLayout::new(vec![LayoutGroup::Row { widgets }]);

View File

@ -21,6 +21,10 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
fill: Fill, fill: Fill,
}, },
BlendingFillSet {
layer: LayerNodeIdentifier,
fill: f64,
},
OpacitySet { OpacitySet {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
opacity: f64, opacity: f64,
@ -29,6 +33,9 @@ pub enum GraphOperationMessage {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
blend_mode: BlendMode, blend_mode: BlendMode,
}, },
ClipModeToggle {
layer: LayerNodeIdentifier,
},
StrokeSet { StrokeSet {
layer: LayerNodeIdentifier, layer: LayerNodeIdentifier,
stroke: Stroke, stroke: Stroke,

View File

@ -5,12 +5,13 @@ use crate::messages::portfolio::document::utility_types::document_metadata::Laye
use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector}; use crate::messages::portfolio::document::utility_types::network_interface::{InputConnector, NodeNetworkInterface, OutputConnector};
use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers; use crate::messages::portfolio::document::utility_types::nodes::CollapsedLayers;
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::graph_modification_utils::get_clip_mode;
use glam::{DAffine2, DVec2, IVec2}; use glam::{DAffine2, DVec2, IVec2};
use graph_craft::document::{NodeId, NodeInput}; use graph_craft::document::{NodeId, NodeInput};
use graphene_core::Color; use graphene_core::Color;
use graphene_core::renderer::Quad; use graphene_core::renderer::Quad;
use graphene_core::text::{Font, TypesettingConfig}; use graphene_core::text::{Font, TypesettingConfig};
use graphene_core::vector::style::{Fill, Gradient, GradientStops, GradientType, LineCap, LineJoin, Stroke}; use graphene_core::vector::style::{Fill, Gradient, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::convert_usvg_path; use graphene_std::vector::convert_usvg_path;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -41,6 +42,11 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageData<'_>> for Gr
modify_inputs.fill_set(fill); modify_inputs.fill_set(fill);
} }
} }
GraphOperationMessage::BlendingFillSet { layer, fill } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.blending_fill_set(fill);
}
}
GraphOperationMessage::OpacitySet { layer, opacity } => { GraphOperationMessage::OpacitySet { layer, opacity } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.opacity_set(opacity); modify_inputs.opacity_set(opacity);
@ -51,6 +57,12 @@ impl MessageHandler<GraphOperationMessage, GraphOperationMessageData<'_>> for Gr
modify_inputs.blend_mode_set(blend_mode); modify_inputs.blend_mode_set(blend_mode);
} }
} }
GraphOperationMessage::ClipModeToggle { layer } => {
let clip_mode = get_clip_mode(layer, network_interface);
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.clip_mode_toggle(clip_mode);
}
}
GraphOperationMessage::StrokeSet { layer, stroke } => { GraphOperationMessage::StrokeSet { layer, stroke } => {
if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) { if let Some(mut modify_inputs) = ModifyInputsContext::new_with_layer(layer, network_interface, responses) {
modify_inputs.stroke_set(stroke); modify_inputs.stroke_set(stroke);
@ -376,18 +388,20 @@ fn apply_usvg_stroke(stroke: &usvg::Stroke, modify_inputs: &mut ModifyInputsCont
weight: stroke.width().get() as f64, weight: stroke.width().get() as f64,
dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(), dash_lengths: stroke.dasharray().as_ref().map(|lengths| lengths.iter().map(|&length| length as f64).collect()).unwrap_or_default(),
dash_offset: stroke.dashoffset() as f64, dash_offset: stroke.dashoffset() as f64,
line_cap: match stroke.linecap() { cap: match stroke.linecap() {
usvg::LineCap::Butt => LineCap::Butt, usvg::LineCap::Butt => StrokeCap::Butt,
usvg::LineCap::Round => LineCap::Round, usvg::LineCap::Round => StrokeCap::Round,
usvg::LineCap::Square => LineCap::Square, usvg::LineCap::Square => StrokeCap::Square,
}, },
line_join: match stroke.linejoin() { join: match stroke.linejoin() {
usvg::LineJoin::Miter => LineJoin::Miter, usvg::LineJoin::Miter => StrokeJoin::Miter,
usvg::LineJoin::MiterClip => LineJoin::Miter, usvg::LineJoin::MiterClip => StrokeJoin::Miter,
usvg::LineJoin::Round => LineJoin::Round, usvg::LineJoin::Round => StrokeJoin::Round,
usvg::LineJoin::Bevel => LineJoin::Bevel, usvg::LineJoin::Bevel => StrokeJoin::Bevel,
}, },
line_join_miter_limit: stroke.miterlimit().get() as f64, join_miter_limit: stroke.miterlimit().get() as f64,
align: StrokeAlign::Center,
paint_order: PaintOrder::StrokeAbove,
transform, transform,
non_scaling: false, non_scaling: false,
}) })

View File

@ -15,8 +15,8 @@ use graphene_core::text::{Font, TypesettingConfig};
use graphene_core::vector::brush_stroke::BrushStroke; use graphene_core::vector::brush_stroke::BrushStroke;
use graphene_core::vector::style::{Fill, Stroke}; use graphene_core::vector::style::{Fill, Stroke};
use graphene_core::vector::{PointId, VectorModificationType}; use graphene_core::vector::{PointId, VectorModificationType};
use graphene_std::GraphicGroupTable;
use graphene_std::vector::{VectorData, VectorDataTable}; use graphene_std::vector::{VectorData, VectorDataTable};
use graphene_std::{GraphicGroupTable, NodeInputDecleration};
#[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] #[derive(PartialEq, Clone, Copy, Debug, serde::Serialize, serde::Deserialize)]
pub enum TransformIn { pub enum TransformIn {
@ -58,13 +58,13 @@ impl<'a> ModifyInputsContext<'a> {
/// Non layer nodes directly upstream of a layer are treated as part of that layer. See insert_index == 2 in the diagram /// Non layer nodes directly upstream of a layer are treated as part of that layer. See insert_index == 2 in the diagram
/// -----> Post node /// -----> Post node
/// | if insert_index == 0, return (Post node, Some(Layer1)) /// | if insert_index == 0, return (Post node, Some(Layer1))
/// -> Layer1 /// -> Layer1
/// ↑ if insert_index == 1, return (Layer1, Some(Layer2)) /// ↑ if insert_index == 1, return (Layer1, Some(Layer2))
/// -> Layer2 /// -> Layer2
/// ↑ /// ↑
/// -> NonLayerNode /// -> NonLayerNode
/// ↑ if insert_index == 2, return (NonLayerNode, Some(Layer3)) /// ↑ if insert_index == 2, return (NonLayerNode, Some(Layer3))
/// -> Layer3 /// -> Layer3
/// if insert_index == 3, return (Layer3, None) /// if insert_index == 3, return (Layer3, None)
pub fn get_post_node_with_index(network_interface: &NodeNetworkInterface, parent: LayerNodeIdentifier, insert_index: usize) -> InputConnector { pub fn get_post_node_with_index(network_interface: &NodeNetworkInterface, parent: LayerNodeIdentifier, insert_index: usize) -> InputConnector {
let mut post_node_input_connector = if parent == LayerNodeIdentifier::ROOT_PARENT { let mut post_node_input_connector = if parent == LayerNodeIdentifier::ROOT_PARENT {
@ -333,37 +333,52 @@ impl<'a> ModifyInputsContext<'a> {
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Fill(fill), false), false); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Fill(fill), false), false);
} }
pub fn blend_mode_set(&mut self, blend_mode: BlendMode) {
let Some(blend_node_id) = self.existing_node_id("Blending", true) else { return };
let input_connector = InputConnector::node(blend_node_id, 1);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::BlendMode(blend_mode), false), false);
}
pub fn opacity_set(&mut self, opacity: f64) { pub fn opacity_set(&mut self, opacity: f64) {
let Some(opacity_node_id) = self.existing_node_id("Opacity", true) else { return }; let Some(blend_node_id) = self.existing_node_id("Blending", true) else { return };
let input_connector = InputConnector::node(opacity_node_id, 1); let input_connector = InputConnector::node(blend_node_id, 2);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(opacity * 100.), false), false); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(opacity * 100.), false), false);
} }
pub fn blend_mode_set(&mut self, blend_mode: BlendMode) { pub fn blending_fill_set(&mut self, fill: f64) {
let Some(blend_mode_node_id) = self.existing_node_id("Blend Mode", true) else { let Some(blend_node_id) = self.existing_node_id("Blending", true) else { return };
return; let input_connector = InputConnector::node(blend_node_id, 3);
}; self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(fill * 100.), false), false);
let input_connector = InputConnector::node(blend_mode_node_id, 1); }
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::BlendMode(blend_mode), false), false);
pub fn clip_mode_toggle(&mut self, clip_mode: Option<bool>) {
let clip = !clip_mode.unwrap_or(false);
let Some(clip_node_id) = self.existing_node_id("Blending", true) else { return };
let input_connector = InputConnector::node(clip_node_id, 4);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::Bool(clip), false), false);
} }
pub fn stroke_set(&mut self, stroke: Stroke) { pub fn stroke_set(&mut self, stroke: Stroke) {
let Some(stroke_node_id) = self.existing_node_id("Stroke", true) else { return }; let Some(stroke_node_id) = self.existing_node_id("Stroke", true) else { return };
let input_connector = InputConnector::node(stroke_node_id, 1); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::ColorInput::<Option<graphene_std::Color>>::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::OptionalColor(stroke.color), false), true); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::OptionalColor(stroke.color), false), true);
let input_connector = InputConnector::node(stroke_node_id, 2); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::WeightInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.weight), false), true); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.weight), false), true);
let input_connector = InputConnector::node(stroke_node_id, 3); let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::AlignInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::StrokeAlign(stroke.align), false), false);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::CapInput::INDEX);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::StrokeCap(stroke.cap), false), true);
let input_connector = InputConnector::node(stroke_node_id, graphene_std::vector::stroke::JoinInput::INDEX);
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::INDEX);
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::VecF64(stroke.dash_lengths), false), true); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::VecF64(stroke.dash_lengths), false), true);
let input_connector = InputConnector::node(stroke_node_id, 4); 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); self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.dash_offset), false), true);
let input_connector = InputConnector::node(stroke_node_id, 5);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::LineCap(stroke.line_cap), false), true);
let input_connector = InputConnector::node(stroke_node_id, 6);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::LineJoin(stroke.line_join), false), true);
let input_connector = InputConnector::node(stroke_node_id, 7);
self.set_input_with_refresh(input_connector, NodeInput::value(TaggedValue::F64(stroke.line_join_miter_limit), false), false);
} }
/// Update the transform value of the upstream Transform node based a change to its existing value and the given parent transform. /// Update the transform value of the upstream Transform node based a change to its existing value and the given parent transform.

View File

@ -15,6 +15,7 @@ use crate::messages::portfolio::document::utility_types::network_interface::{
use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry}; use crate::messages::portfolio::document::utility_types::nodes::{CollapsedLayers, LayerPanelEntry};
use crate::messages::prelude::*; use crate::messages::prelude::*;
use crate::messages::tool::common_functionality::auto_panning::AutoPanning; use crate::messages::tool::common_functionality::auto_panning::AutoPanning;
use crate::messages::tool::common_functionality::graph_modification_utils::get_clip_mode;
use crate::messages::tool::tool_messages::tool_prelude::{Key, MouseMotion}; use crate::messages::tool::tool_messages::tool_prelude::{Key, MouseMotion};
use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo}; use crate::messages::tool::utility_types::{HintData, HintGroup, HintInfo};
use glam::{DAffine2, DVec2, IVec2}; use glam::{DAffine2, DVec2, IVec2};
@ -2442,6 +2443,7 @@ impl NodeGraphMessageHandler {
} }
}); });
let clippable = layer.can_be_clipped(network_interface.document_metadata());
let data = LayerPanelEntry { let data = LayerPanelEntry {
id: node_id, id: node_id,
alias: network_interface.display_name(&node_id, &[]), alias: network_interface.display_name(&node_id, &[]),
@ -2461,6 +2463,8 @@ impl NodeGraphMessageHandler {
selected: selected_layers.contains(&node_id), selected: selected_layers.contains(&node_id),
ancestor_of_selected: ancestors_of_selected.contains(&node_id), ancestor_of_selected: ancestors_of_selected.contains(&node_id),
descendant_of_selected: descendants_of_selected.contains(&node_id), descendant_of_selected: descendants_of_selected.contains(&node_id),
clipped: get_clip_mode(layer, network_interface).unwrap_or(false) && clippable,
clippable,
}; };
responses.add(FrontendMessage::UpdateDocumentLayerDetails { data }); responses.add(FrontendMessage::UpdateDocumentLayerDetails { data });
} }

View File

@ -20,7 +20,7 @@ use graphene_core::raster_types::{CPU, GPU, RasterDataTable};
use graphene_core::text::Font; use graphene_core::text::Font;
use graphene_core::vector::generator_nodes::grid; use graphene_core::vector::generator_nodes::grid;
use graphene_core::vector::misc::CentroidType; use graphene_core::vector::misc::CentroidType;
use graphene_core::vector::style::{GradientType, LineCap, LineJoin}; use graphene_core::vector::style::{GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::animation::RealTimeMode; use graphene_std::animation::RealTimeMode;
use graphene_std::ops::XY; use graphene_std::ops::XY;
use graphene_std::transform::{Footprint, ReferencePoint}; use graphene_std::transform::{Footprint, ReferencePoint};
@ -233,8 +233,10 @@ pub(crate) fn property_from_type(
Some(x) if x == TypeId::of::<DomainWarpType>() => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(), Some(x) if x == TypeId::of::<DomainWarpType>() => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if x == TypeId::of::<RelativeAbsolute>() => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(), Some(x) if x == TypeId::of::<RelativeAbsolute>() => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(),
Some(x) if x == TypeId::of::<GridType>() => enum_choice::<GridType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<GridType>() => enum_choice::<GridType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<LineCap>() => enum_choice::<LineCap>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<StrokeCap>() => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<LineJoin>() => enum_choice::<LineJoin>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<StrokeJoin>() => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<StrokeAlign>() => enum_choice::<StrokeAlign>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<PaintOrder>() => enum_choice::<PaintOrder>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<ArcType>() => enum_choice::<ArcType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<ArcType>() => enum_choice::<ArcType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<BooleanOperation>() => enum_choice::<BooleanOperation>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<BooleanOperation>() => enum_choice::<BooleanOperation>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<CentroidType>() => enum_choice::<CentroidType>().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::<CentroidType>() => enum_choice::<CentroidType>().for_socket(default_info).property_row(),
@ -1679,20 +1681,43 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
return Vec::new(); return Vec::new();
} }
}; };
let color_index = 1; let color_index = graphene_std::vector::stroke::ColorInput::<Option<Color>>::INDEX;
let weight_index = 2; let weight_index = graphene_std::vector::stroke::WeightInput::INDEX;
let dash_lengths_index = 3; let align_index = graphene_std::vector::stroke::AlignInput::INDEX;
let dash_offset_index = 4; let cap_index = graphene_std::vector::stroke::CapInput::INDEX;
let line_cap_index = 5; let join_index = graphene_std::vector::stroke::JoinInput::INDEX;
let line_join_index = 6; let miter_limit_index = graphene_std::vector::stroke::MiterLimitInput::INDEX;
let miter_limit_index = 7; let paint_order_index = graphene_std::vector::stroke::PaintOrderInput::INDEX;
let dash_lengths_index = graphene_std::vector::stroke::DashLengthsInput::INDEX;
let dash_offset_index = graphene_std::vector::stroke::DashOffsetInput::INDEX;
let color = color_widget(ParameterWidgetsInfo::from_index(document_node, node_id, color_index, true, context), ColorInput::default()); let color = color_widget(ParameterWidgetsInfo::from_index(document_node, node_id, color_index, true, context), ColorInput::default());
let weight = number_widget( let weight = number_widget(
ParameterWidgetsInfo::from_index(document_node, node_id, weight_index, true, context), ParameterWidgetsInfo::from_index(document_node, node_id, weight_index, true, context),
NumberInput::default().unit(" px").min(0.), NumberInput::default().unit(" px").min(0.),
); );
let align = enum_choice::<StrokeAlign>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, align_index, true, context))
.property_row();
let cap = enum_choice::<StrokeCap>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, cap_index, true, context))
.property_row();
let join = enum_choice::<StrokeJoin>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, join_index, true, context))
.property_row();
let miter_limit = number_widget(
ParameterWidgetsInfo::from_index(document_node, node_id, miter_limit_index, true, context),
NumberInput::default().min(0.).disabled({
let join_value = match &document_node.inputs[join_index].as_value() {
Some(TaggedValue::StrokeJoin(x)) => x,
_ => &StrokeJoin::Miter,
};
join_value != &StrokeJoin::Miter
}),
);
let paint_order = enum_choice::<PaintOrder>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, paint_order_index, true, context))
.property_row();
let dash_lengths_val = match &document_node.inputs[dash_lengths_index].as_value() { let dash_lengths_val = match &document_node.inputs[dash_lengths_index].as_value() {
Some(TaggedValue::VecF64(x)) => x, Some(TaggedValue::VecF64(x)) => x,
_ => &vec![], _ => &vec![],
@ -1703,29 +1728,17 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
); );
let number_input = NumberInput::default().unit(" px").disabled(dash_lengths_val.is_empty()); let number_input = NumberInput::default().unit(" px").disabled(dash_lengths_val.is_empty());
let dash_offset = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, dash_offset_index, true, context), number_input); let dash_offset = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, dash_offset_index, true, context), number_input);
let line_cap = enum_choice::<LineCap>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, line_cap_index, true, context))
.property_row();
let line_join = enum_choice::<LineJoin>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, line_join_index, true, context))
.property_row();
let line_join_val = match &document_node.inputs[line_join_index].as_value() {
Some(TaggedValue::LineJoin(x)) => x,
_ => &LineJoin::Miter,
};
let miter_limit = number_widget(
ParameterWidgetsInfo::from_index(document_node, node_id, miter_limit_index, true, context),
NumberInput::default().min(0.).disabled(line_join_val != &LineJoin::Miter),
);
vec![ vec![
color, color,
LayoutGroup::Row { widgets: weight }, LayoutGroup::Row { widgets: weight },
align,
cap,
join,
LayoutGroup::Row { widgets: miter_limit },
paint_order,
LayoutGroup::Row { widgets: dash_lengths }, LayoutGroup::Row { widgets: dash_lengths },
LayoutGroup::Row { widgets: dash_offset }, LayoutGroup::Row { widgets: dash_offset },
line_cap,
line_join,
LayoutGroup::Row { widgets: miter_limit },
] ]
} }
@ -1737,25 +1750,27 @@ pub fn offset_path_properties(node_id: NodeId, context: &mut NodePropertiesConte
return Vec::new(); return Vec::new();
} }
}; };
let distance_index = 1; let distance_index = graphene_std::vector::offset_path::DistanceInput::INDEX;
let line_join_index = 2; let join_index = graphene_std::vector::offset_path::JoinInput::INDEX;
let miter_limit_index = 3; let miter_limit_index = graphene_std::vector::offset_path::MiterLimitInput::INDEX;
let number_input = NumberInput::default().unit(" px"); let number_input = NumberInput::default().unit(" px");
let distance = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, distance_index, true, context), number_input); let distance = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, distance_index, true, context), number_input);
let line_join = enum_choice::<LineJoin>() let join = enum_choice::<StrokeJoin>()
.for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, line_join_index, true, context)) .for_socket(ParameterWidgetsInfo::from_index(document_node, node_id, join_index, true, context))
.property_row(); .property_row();
let line_join_val = match &document_node.inputs[line_join_index].as_value() {
Some(TaggedValue::LineJoin(x)) => x,
_ => &LineJoin::Miter,
};
let number_input = NumberInput::default().min(0.).disabled(line_join_val != &LineJoin::Miter); let number_input = NumberInput::default().min(0.).disabled({
let join_val = match &document_node.inputs[join_index].as_value() {
Some(TaggedValue::StrokeJoin(x)) => x,
_ => &StrokeJoin::Miter,
};
join_val != &StrokeJoin::Miter
});
let miter_limit = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, miter_limit_index, true, context), number_input); let miter_limit = number_widget(ParameterWidgetsInfo::from_index(document_node, node_id, miter_limit_index, true, context), number_input);
vec![LayoutGroup::Row { widgets: distance }, line_join, LayoutGroup::Row { widgets: miter_limit }] vec![LayoutGroup::Row { widgets: distance }, join, LayoutGroup::Row { widgets: miter_limit }]
} }
pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {

View File

@ -287,6 +287,12 @@ impl LayerNodeIdentifier {
child.ancestors(metadata).any(|ancestor| ancestor == self) child.ancestors(metadata).any(|ancestor| ancestor == self)
} }
/// Is the layer last child of parent group? Used for clipping
pub fn can_be_clipped(self, metadata: &DocumentMetadata) -> bool {
self.parent(metadata)
.map_or(false, |layer| layer.last_child(metadata).expect("Parent accessed via child should have children") != self)
}
/// Iterator over all direct children (excluding self and recursive children) /// Iterator over all direct children (excluding self and recursive children)
pub fn children(self, metadata: &DocumentMetadata) -> AxisIter { pub fn children(self, metadata: &DocumentMetadata) -> AxisIter {
AxisIter { AxisIter {

View File

@ -1160,6 +1160,13 @@ impl NodeNetworkInterface {
.and_then(|node_metadata| node_metadata.persistent_metadata.input_properties.get(index)) .and_then(|node_metadata| node_metadata.persistent_metadata.input_properties.get(index))
} }
pub fn insert_input_properties_row(&mut self, node_id: &NodeId, index: usize, network_path: &[NodeId]) {
let row = ("", "TODO").into();
let _ = self
.node_metadata_mut(node_id, network_path)
.map(|node_metadata| node_metadata.persistent_metadata.input_properties.insert(index - 1, row));
}
pub fn input_metadata(&self, node_id: &NodeId, index: usize, field: &str, network_path: &[NodeId]) -> Option<&Value> { pub fn input_metadata(&self, node_id: &NodeId, index: usize, field: &str, network_path: &[NodeId]) -> Option<&Value> {
let Some(input_row) = self.input_properties_row(node_id, index, network_path) else { let Some(input_row) = self.input_properties_row(node_id, index, network_path) else {
log::error!("Could not get input_row in get_input_metadata"); log::error!("Could not get input_row in get_input_metadata");

View File

@ -55,6 +55,8 @@ pub struct LayerPanelEntry {
pub ancestor_of_selected: bool, pub ancestor_of_selected: bool,
#[serde(rename = "descendantOfSelected")] #[serde(rename = "descendantOfSelected")]
pub descendant_of_selected: bool, pub descendant_of_selected: bool,
pub clipped: bool,
pub clippable: bool,
} }
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, specta::Type)] #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq, specta::Type)]

View File

@ -24,7 +24,7 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeInput}; use graph_craft::document::{DocumentNodeImplementation, NodeId, NodeInput};
use graphene_core::renderer::Quad; use graphene_core::renderer::Quad;
use graphene_core::text::{Font, TypesettingConfig}; use graphene_core::text::{Font, TypesettingConfig};
use graphene_std::vector::style::{Fill, FillType, Gradient}; use graphene_std::vector::style::{Fill, FillType, Gradient, PaintOrder, StrokeAlign};
use graphene_std::vector::{VectorData, VectorDataTable}; use graphene_std::vector::{VectorData, VectorDataTable};
use std::vec; use std::vec;
@ -678,6 +678,30 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageData<'_>> for PortfolioMes
} }
} }
// Upgrade Stroke node to reorder parameters and add "Align" and "Paint Order" (#2644)
if reference == "Stroke" && inputs_count == 8 {
let node_definition = resolve_document_node_type(reference).unwrap();
let document_node = node_definition.default_node_template().document_node;
document.network_interface.replace_implementation(node_id, network_path, document_node.implementation.clone());
document.network_interface.insert_input_properties_row(node_id, 8, network_path);
document.network_interface.insert_input_properties_row(node_id, 9, network_path);
let old_inputs = document.network_interface.replace_inputs(node_id, document_node.inputs.clone(), network_path);
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(*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), align_input, 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[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);
document.network_interface.set_input(&InputConnector::node(*node_id, 9), old_inputs[4].clone(), network_path);
}
// Rename the old "Splines from Points" node to "Spline" and upgrade it to the new "Spline" node // Rename the old "Splines from Points" node to "Spline" and upgrade it to the new "Spline" node
if reference == "Splines from Points" { if reference == "Splines from Points" {
document.network_interface.set_reference(node_id, network_path, Some("Spline".to_string())); document.network_interface.set_reference(node_id, network_path, Some("Spline".to_string()));

View File

@ -13,6 +13,7 @@ use graphene_core::raster::BlendMode;
use graphene_core::raster_types::{CPU, GPU, RasterDataTable}; use graphene_core::raster_types::{CPU, GPU, RasterDataTable};
use graphene_core::text::{Font, TypesettingConfig}; use graphene_core::text::{Font, TypesettingConfig};
use graphene_core::vector::style::Gradient; use graphene_core::vector::style::Gradient;
use graphene_std::NodeInputDecleration;
use graphene_std::vector::{ManipulatorPointId, PointId, SegmentId, VectorModificationType}; use graphene_std::vector::{ManipulatorPointId, PointId, SegmentId, VectorModificationType};
use std::collections::VecDeque; use std::collections::VecDeque;
@ -258,7 +259,7 @@ pub fn get_viewport_pivot(layer: LayerNodeIdentifier, network_interface: &NodeNe
network_interface.document_metadata().transform_to_viewport(layer).transform_point2(min + (max - min) * pivot) network_interface.document_metadata().transform_to_viewport(layer).transform_point2(min + (max - min) * pivot)
} }
/// Get the current gradient of a layer from the closest Fill node /// Get the current gradient of a layer from the closest "Fill" node.
pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Gradient> { pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Gradient> {
let fill_index = 1; let fill_index = 1;
@ -269,7 +270,7 @@ pub fn get_gradient(layer: LayerNodeIdentifier, network_interface: &NodeNetworkI
Some(gradient.clone()) Some(gradient.clone())
} }
/// Get the current fill of a layer from the closest Fill node /// Get the current fill of a layer from the closest "Fill" node.
pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Color> { pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<Color> {
let fill_index = 1; let fill_index = 1;
@ -280,16 +281,16 @@ pub fn get_fill_color(layer: LayerNodeIdentifier, network_interface: &NodeNetwor
Some(color.to_linear_srgb()) Some(color.to_linear_srgb())
} }
/// Get the current blend mode of a layer from the closest Blend Mode node /// Get the current blend mode of a layer from the closest "Blending" node.
pub fn get_blend_mode(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<BlendMode> { pub fn get_blend_mode(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<BlendMode> {
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Blend Mode")?; let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Blending")?;
let TaggedValue::BlendMode(blend_mode) = inputs.get(1)?.as_value()? else { let TaggedValue::BlendMode(blend_mode) = inputs.get(1)?.as_value()? else {
return None; return None;
}; };
Some(*blend_mode) Some(*blend_mode)
} }
/// Get the current opacity of a layer from the closest Opacity node. /// Get the current opacity of a layer from the closest "Blending" node.
/// This may differ from the actual opacity contained within the data type reaching this layer, because that actual opacity may be: /// This may differ from the actual opacity contained within the data type reaching this layer, because that actual opacity may be:
/// - Multiplied with additional opacity nodes earlier in the chain /// - Multiplied with additional opacity nodes earlier in the chain
/// - Set by an Opacity node with an exposed input value driven by another node /// - Set by an Opacity node with an exposed input value driven by another node
@ -298,13 +299,29 @@ pub fn get_blend_mode(layer: LayerNodeIdentifier, network_interface: &NodeNetwor
/// ///
/// With those limitations in mind, the intention of this function is to show just the value already present in an upstream Opacity node so that value can be directly edited. /// With those limitations in mind, the intention of this function is to show just the value already present in an upstream Opacity node so that value can be directly edited.
pub fn get_opacity(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<f64> { pub fn get_opacity(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<f64> {
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Opacity")?; let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Blending")?;
let TaggedValue::F64(opacity) = inputs.get(1)?.as_value()? else { let TaggedValue::F64(opacity) = inputs.get(2)?.as_value()? else {
return None; return None;
}; };
Some(*opacity) Some(*opacity)
} }
pub fn get_clip_mode(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<bool> {
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Blending")?;
let TaggedValue::Bool(clip) = inputs.get(4)?.as_value()? else {
return None;
};
Some(*clip)
}
pub fn get_fill(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<f64> {
let inputs = NodeGraphLayer::new(layer, network_interface).find_node_inputs("Blending")?;
let TaggedValue::F64(fill) = inputs.get(3)?.as_value()? else {
return None;
};
Some(*fill)
}
pub fn get_fill_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> { pub fn get_fill_id(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<NodeId> {
NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name("Fill") NodeGraphLayer::new(layer, network_interface).upstream_node_id_from_name("Fill")
} }
@ -356,7 +373,7 @@ pub fn get_text(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInter
} }
pub fn get_stroke_width(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<f64> { pub fn get_stroke_width(layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface) -> Option<f64> {
let weight_node_input_index = 2; let weight_node_input_index = graphene_std::vector::stroke::WeightInput::INDEX;
if let TaggedValue::F64(width) = NodeGraphLayer::new(layer, network_interface).find_input("Stroke", weight_node_input_index)? { if let TaggedValue::F64(width) = NodeGraphLayer::new(layer, network_interface).find_input("Stroke", weight_node_input_index)? {
Some(*width) Some(*width)
} else { } else {

View File

@ -338,7 +338,15 @@ impl NodeGraphExecutor {
fn debug_render(render_object: impl GraphicElementRendered, transform: DAffine2, responses: &mut VecDeque<Message>) { fn debug_render(render_object: impl GraphicElementRendered, transform: DAffine2, responses: &mut VecDeque<Message>) {
// Setup rendering // Setup rendering
let mut render = SvgRender::new(); let mut render = SvgRender::new();
let render_params = RenderParams::new(ViewMode::Normal, None, false, false, false); let render_params = RenderParams {
view_mode: ViewMode::Normal,
culling_bounds: None,
thumbnail: false,
hide_artboards: false,
for_export: false,
for_mask: false,
alignment_parent_transform: None,
};
// Render SVG // Render SVG
render_object.render_svg(&mut render, &render_params); render_object.render_svg(&mut render, &render_params);

View File

@ -323,7 +323,15 @@ impl NodeRuntime {
let bounds = graphic_element.bounding_box(DAffine2::IDENTITY, true); let bounds = graphic_element.bounding_box(DAffine2::IDENTITY, true);
// Render the thumbnail from a `GraphicElement` into an SVG string // Render the thumbnail from a `GraphicElement` into an SVG string
let render_params = RenderParams::new(ViewMode::Normal, bounds, true, false, false); let render_params = RenderParams {
view_mode: ViewMode::Normal,
culling_bounds: bounds,
thumbnail: true,
hide_artboards: false,
for_export: false,
for_mask: false,
alignment_parent_transform: None,
};
let mut render = SvgRender::new(); let mut render = SvgRender::new();
graphic_element.render_svg(&mut render, &render_params); graphic_element.render_svg(&mut render, &render_params);

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getContext, onMount, tick } from "svelte"; import { getContext, onMount, onDestroy, tick } from "svelte";
import type { Editor } from "@graphite/editor"; import type { Editor } from "@graphite/editor";
import { beginDraggingElement } from "@graphite/io-managers/drag"; import { beginDraggingElement } from "@graphite/io-managers/drag";
@ -55,6 +55,10 @@
let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined; let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined;
let dragInPanel = false; let dragInPanel = false;
// Interactive clipping
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
let layerToClipAltKeyPressed = false;
// Layouts // Layouts
let layersPanelControlBarLeftLayout = defaultWidgetLayout(); let layersPanelControlBarLeftLayout = defaultWidgetLayout();
let layersPanelControlBarRightLayout = defaultWidgetLayout(); let layersPanelControlBarRightLayout = defaultWidgetLayout();
@ -87,6 +91,16 @@
updateLayerInTree(targetId, targetLayer); updateLayerInTree(targetId, targetLayer);
}); });
addEventListener("pointermove", clippingHover);
addEventListener("keydown", clippingKeyPress);
addEventListener("keyup", clippingKeyPress);
});
onDestroy(() => {
removeEventListener("pointermove", clippingHover);
removeEventListener("keydown", clippingKeyPress);
removeEventListener("keyup", clippingKeyPress);
}); });
type DocumentLayerStructure = { type DocumentLayerStructure = {
@ -208,12 +222,58 @@
// Get the state of the platform's accel key and its opposite platform's accel key // Get the state of the platform's accel key and its opposite platform's accel key
const [accel, oppositeAccel] = platformIsMac() ? [meta, ctrl] : [ctrl, meta]; const [accel, oppositeAccel] = platformIsMac() ? [meta, ctrl] : [ctrl, meta];
// Alt-clicking to make a clipping mask
if (layerToClipAltKeyPressed && layerToClipUponClick && layerToClipUponClick.entry.clippable) clipLayer(layerToClipUponClick);
// Select the layer only if the accel and/or shift keys are pressed // Select the layer only if the accel and/or shift keys are pressed
if (!oppositeAccel && !alt) selectLayer(listing, accel, shift); else if (!oppositeAccel && !alt) selectLayer(listing, accel, shift);
e.stopPropagation(); e.stopPropagation();
} }
function clipLayer(listing: LayerListingInfo) {
editor.handle.clipLayer(listing.entry.id);
}
function clippingKeyPress(e: KeyboardEvent) {
layerToClipAltKeyPressed = e.altKey;
}
function clippingHover(e: PointerEvent) {
// Don't do anything if the user is dragging to rearrange layers
if (dragInPanel) return;
// Get the layer below the cursor
const target = (e.target instanceof HTMLElement && e.target.closest("[data-layer]")) || undefined;
if (!target) {
layerToClipUponClick = undefined;
return;
}
// Check if the cursor is near the border btween two layers
const DISTANCE = 6;
const distanceFromTop = e.clientY - target.getBoundingClientRect().top;
const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY;
const nearTop = distanceFromTop < DISTANCE;
const nearBottom = distanceFromBottom < DISTANCE;
// If we are not near the border, we don't want to clip
if (!nearTop && !nearBottom) {
layerToClipUponClick = undefined;
return;
}
// If we are near the border, we want to clip the layer above the border
const indexAttribute = target?.getAttribute("data-index") ?? undefined;
const index = indexAttribute ? Number(indexAttribute) : undefined;
const layer = index !== undefined && layers[nearTop ? index - 1 : index];
if (!layer) return;
// Update the state used to show the clipping action
layerToClipUponClick = layer;
layerToClipAltKeyPressed = e.altKey;
}
function selectLayer(listing: LayerListingInfo, accel: boolean, shift: boolean) { function selectLayer(listing: LayerListingInfo, accel: boolean, shift: boolean) {
// Don't select while we are entering text to rename the layer // Don't select while we are entering text to rename the layer
if (listing.editingName) return; if (listing.editingName) return;
@ -433,7 +493,16 @@
<WidgetLayout layout={layersPanelControlBarRightLayout} /> <WidgetLayout layout={layersPanelControlBarRightLayout} />
</LayoutRow> </LayoutRow>
<LayoutRow class="list-area" scrollableY={true}> <LayoutRow class="list-area" scrollableY={true}>
<LayoutCol class="list" data-layer-panel bind:this={list} on:click={() => deselectAllLayers()} on:dragover={updateInsertLine} on:dragend={drop} on:drop={drop}> <LayoutCol
class="list"
styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }}
data-layer-panel
bind:this={list}
on:click={() => deselectAllLayers()}
on:dragover={updateInsertLine}
on:dragend={drop}
on:drop={drop}
>
{#each layers as listing, index} {#each layers as listing, index}
{@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected} {@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected}
<LayoutRow <LayoutRow
@ -464,6 +533,11 @@
on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)} on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)}
tabindex="0" tabindex="0"
></button> ></button>
{:else}
<div class="expand-arrow-none"></div>
{/if}
{#if listing.entry.clipped}
<IconLabel icon="Clipped" class="clipped-arrow" tooltip={"Clipping mask is active (Alt-click border to release)"} />
{/if} {/if}
<div class="thumbnail"> <div class="thumbnail">
{#if $nodeGraph.thumbnails.has(listing.entry.id)} {#if $nodeGraph.thumbnails.has(listing.entry.id)}
@ -589,6 +663,7 @@
.expand-arrow { .expand-arrow {
padding: 0; padding: 0;
margin: 0; margin: 0;
margin-right: 4px;
width: 16px; width: 16px;
height: 100%; height: 100%;
border: none; border: none;
@ -625,10 +700,19 @@
} }
} }
.expand-arrow-none {
flex: 0 0 16px;
margin-right: 4px;
}
.clipped-arrow {
margin-left: 2px;
margin-right: 2px;
}
.thumbnail { .thumbnail {
width: 36px; width: 36px;
height: 24px; height: 24px;
margin-left: 4px;
border-radius: 2px; border-radius: 2px;
flex: 0 0 auto; flex: 0 0 auto;
background-image: var(--color-transparent-checkered-background); background-image: var(--color-transparent-checkered-background);
@ -636,10 +720,6 @@
background-position: var(--color-transparent-checkered-background-position-mini); background-position: var(--color-transparent-checkered-background-position-mini);
background-repeat: var(--color-transparent-checkered-background-repeat); background-repeat: var(--color-transparent-checkered-background-repeat);
&:first-child {
margin-left: 20px;
}
svg { svg {
width: calc(100% - 4px); width: calc(100% - 4px);
height: calc(100% - 4px); height: calc(100% - 4px);

View File

@ -907,6 +907,10 @@ export class LayerPanelEntry {
ancestorOfSelected!: boolean; ancestorOfSelected!: boolean;
descendantOfSelected!: boolean; descendantOfSelected!: boolean;
clipped!: boolean;
clippable!: boolean;
} }
export class DisplayDialogDismiss extends JsMessage {} export class DisplayDialogDismiss extends JsMessage {}

View File

@ -504,6 +504,13 @@ impl EditorHandle {
self.dispatch(message); self.dispatch(message);
} }
#[wasm_bindgen(js_name = clipLayer)]
pub fn clip_layer(&self, id: u64) {
let id = NodeId(id);
let message = DocumentMessage::ClipLayer { id };
self.dispatch(message);
}
/// Modify the layer selection based on the layer which is clicked while holding down the <kbd>Ctrl</kbd> and/or <kbd>Shift</kbd> modifier keys used for range selection behavior /// Modify the layer selection based on the layer which is clicked while holding down the <kbd>Ctrl</kbd> and/or <kbd>Shift</kbd> modifier keys used for range selection behavior
#[wasm_bindgen(js_name = selectLayer)] #[wasm_bindgen(js_name = selectLayer)]
pub fn select_layer(&self, id: u64, ctrl: bool, shift: bool) { pub fn select_layer(&self, id: u64, ctrl: bool, shift: bool) {

View File

@ -14,9 +14,12 @@ pub mod renderer;
#[derive(Copy, Clone, Debug, PartialEq, DynAny, specta::Type)] #[derive(Copy, Clone, Debug, PartialEq, DynAny, specta::Type)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[serde(default)]
pub struct AlphaBlending { pub struct AlphaBlending {
pub opacity: f32,
pub blend_mode: BlendMode, pub blend_mode: BlendMode,
pub opacity: f32,
pub fill: f32,
pub clip: bool,
} }
impl Default for AlphaBlending { impl Default for AlphaBlending {
fn default() -> Self { fn default() -> Self {
@ -26,13 +29,22 @@ impl Default for AlphaBlending {
impl core::hash::Hash for AlphaBlending { impl core::hash::Hash for AlphaBlending {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) { fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.opacity.to_bits().hash(state); self.opacity.to_bits().hash(state);
self.fill.to_bits().hash(state);
self.blend_mode.hash(state); self.blend_mode.hash(state);
self.clip.hash(state);
} }
} }
impl std::fmt::Display for AlphaBlending { impl std::fmt::Display for AlphaBlending {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let round = |x: f32| (x * 1e3).round() / 1e3; let round = |x: f32| (x * 1e3).round() / 1e3;
write!(f, "Opacity: {}% — Blend Mode: {}", round(self.opacity * 100.), self.blend_mode) write!(
f,
"Blend Mode: {} — Opacity: {}% — Fill: {}% — Clip: {}",
self.blend_mode,
round(self.opacity * 100.),
round(self.fill * 100.),
if self.clip { "Yes" } else { "No" }
)
} }
} }
@ -40,7 +52,9 @@ impl AlphaBlending {
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
opacity: 1., opacity: 1.,
fill: 1.,
blend_mode: BlendMode::Normal, blend_mode: BlendMode::Normal,
clip: false,
} }
} }
@ -49,7 +63,9 @@ impl AlphaBlending {
AlphaBlending { AlphaBlending {
opacity: lerp(self.opacity, other.opacity, t), opacity: lerp(self.opacity, other.opacity, t),
fill: lerp(self.fill, other.fill, t),
blend_mode: if t < 0.5 { self.blend_mode } else { other.blend_mode }, blend_mode: if t < 0.5 { self.blend_mode } else { other.blend_mode },
clip: if t < 0.5 { self.clip } else { other.clip },
} }
} }
} }
@ -205,6 +221,26 @@ impl GraphicElement {
_ => None, _ => None,
} }
} }
pub fn had_clip_enabled(&self) -> bool {
match self {
GraphicElement::VectorData(data) => data.instance_ref_iter().all(|instance| instance.alpha_blending.clip),
GraphicElement::GraphicGroup(data) => data.instance_ref_iter().all(|instance| instance.alpha_blending.clip),
GraphicElement::RasterDataCPU(data) => data.instance_ref_iter().all(|instance| instance.alpha_blending.clip),
GraphicElement::RasterDataGPU(data) => data.instance_ref_iter().all(|instance| instance.alpha_blending.clip),
}
}
pub fn can_reduce_to_clip_path(&self) -> bool {
match self {
GraphicElement::VectorData(vector_data_table) => vector_data_table.instance_ref_iter().all(|instance_data| {
let style = &instance_data.instance.style;
let alpha_blending = &instance_data.alpha_blending;
(alpha_blending.opacity > 1. - f32::EPSILON) && style.fill().is_opaque() && style.stroke().is_none_or(|stroke| !stroke.has_renderable_stroke())
}),
_ => false,
}
}
} }
// // TODO: Rename to Raster // // TODO: Rename to Raster
@ -448,8 +484,10 @@ async fn flatten_vector(_: impl Ctx, group: GraphicGroupTable) -> VectorDataTabl
instance: current_element.instance.clone(), instance: current_element.instance.clone(),
transform: *current_instance.transform * *current_element.transform, transform: *current_instance.transform * *current_element.transform,
alpha_blending: AlphaBlending { alpha_blending: AlphaBlending {
opacity: current_instance.alpha_blending.opacity * current_element.alpha_blending.opacity,
blend_mode: current_element.alpha_blending.blend_mode, blend_mode: current_element.alpha_blending.blend_mode,
opacity: current_instance.alpha_blending.opacity * current_element.alpha_blending.opacity,
fill: current_element.alpha_blending.fill,
clip: current_element.alpha_blending.clip,
}, },
source_node_id: reference, source_node_id: reference,
}); });

View File

@ -1,11 +1,12 @@
mod quad; mod quad;
mod rect; mod rect;
use crate::instances::Instance;
use crate::raster::{BlendMode, Image}; use crate::raster::{BlendMode, Image};
use crate::raster_types::{CPU, GPU, RasterDataTable}; use crate::raster_types::{CPU, GPU, RasterDataTable};
use crate::transform::{Footprint, Transform}; use crate::transform::{Footprint, Transform};
use crate::uuid::{NodeId, generate_uuid}; use crate::uuid::{NodeId, generate_uuid};
use crate::vector::style::{Fill, Stroke, ViewMode}; use crate::vector::style::{Fill, Stroke, StrokeAlign, ViewMode};
use crate::vector::{PointId, VectorDataTable}; use crate::vector::{PointId, VectorDataTable};
use crate::{Artboard, ArtboardGroupTable, Color, GraphicElement, GraphicGroupTable}; use crate::{Artboard, ArtboardGroupTable, Color, GraphicElement, GraphicGroupTable};
use base64::Engine; use base64::Engine;
@ -50,6 +51,29 @@ pub struct ClickTarget {
bounding_box: Option<[DVec2; 2]>, bounding_box: Option<[DVec2; 2]>,
} }
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
enum MaskType {
Clip,
Mask,
}
impl MaskType {
fn to_attribute(self) -> String {
match self {
Self::Mask => "mask".to_string(),
Self::Clip => "clip-path".to_string(),
}
}
fn write_to_defs(self, svg_defs: &mut String, uuid: u64, svg_string: String) {
let id = format!("mask-{}", uuid);
match self {
Self::Clip => write!(svg_defs, r##"<clipPath id="{id}">{}</clipPath>"##, svg_string).unwrap(),
Self::Mask => write!(svg_defs, r##"<mask id="{id}" mask-type="alpha">{}</mask>"##, svg_string).unwrap(),
}
}
}
impl ClickTarget { impl ClickTarget {
pub fn new_with_subpath(subpath: bezier_rs::Subpath<PointId>, stroke_width: f64) -> Self { pub fn new_with_subpath(subpath: bezier_rs::Subpath<PointId>, stroke_width: f64) -> Self {
let bounding_box = subpath.loose_bounding_box(); let bounding_box = subpath.loose_bounding_box();
@ -289,17 +313,20 @@ pub struct RenderParams {
pub hide_artboards: bool, pub hide_artboards: bool,
/// Are we exporting? Causes the text above an artboard to be hidden. /// Are we exporting? Causes the text above an artboard to be hidden.
pub for_export: bool, pub for_export: bool,
/// Are we generating a mask in this render pass? Used to see if fill should be multiplied with alpha.
pub for_mask: bool,
/// Are we generating a mask for alignment? Used to prevent unnecesary transforms in masks
pub alignment_parent_transform: Option<DAffine2>,
} }
impl RenderParams { impl RenderParams {
pub fn new(view_mode: ViewMode, culling_bounds: Option<[DVec2; 2]>, thumbnail: bool, hide_artboards: bool, for_export: bool) -> Self { pub fn for_clipper(&self) -> Self {
Self { Self { for_mask: true, ..*self }
view_mode, }
culling_bounds,
thumbnail, pub fn for_alignment(&self, transform: DAffine2) -> Self {
hide_artboards, let alignment_parent_transform = Some(transform);
for_export, Self { alignment_parent_transform, ..*self }
}
} }
} }
@ -362,7 +389,10 @@ pub trait GraphicElementRendered {
impl GraphicElementRendered for GraphicGroupTable { impl GraphicElementRendered for GraphicGroupTable {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for instance in self.instance_ref_iter() { let mut iter = self.instance_ref_iter().peekable();
let mut mask_state = None;
while let Some(instance) = iter.next() {
render.parent_tag( render.parent_tag(
"g", "g",
|attributes| { |attributes| {
@ -371,13 +401,37 @@ impl GraphicElementRendered for GraphicGroupTable {
attributes.push("transform", matrix); attributes.push("transform", matrix);
} }
if instance.alpha_blending.opacity < 1. { let factor = if render_params.for_mask { 1. } else { instance.alpha_blending.fill };
attributes.push("opacity", instance.alpha_blending.opacity.to_string()); let opacity = instance.alpha_blending.opacity * factor;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
} }
if instance.alpha_blending.blend_mode != BlendMode::default() { if instance.alpha_blending.blend_mode != BlendMode::default() {
attributes.push("style", instance.alpha_blending.blend_mode.render()); attributes.push("style", instance.alpha_blending.blend_mode.render());
} }
let next_clips = iter.peek().is_some_and(|next_instance| next_instance.instance.had_clip_enabled());
if next_clips && mask_state.is_none() {
let uuid = generate_uuid();
let mask_type = if instance.instance.can_reduce_to_clip_path() { MaskType::Clip } else { MaskType::Mask };
mask_state = Some((uuid, mask_type));
let mut svg = SvgRender::new();
instance.instance.render_svg(&mut svg, &render_params.for_clipper());
write!(&mut attributes.0.svg_defs, r##"{}"##, svg.svg_defs).unwrap();
mask_type.write_to_defs(&mut attributes.0.svg_defs, uuid, svg.svg.to_svg_string());
} else if let Some((uuid, mask_type)) = mask_state {
if !next_clips {
mask_state = None;
}
let id = format!("mask-{}", uuid);
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
}, },
|render| { |render| {
instance.instance.render_svg(render, render_params); instance.instance.render_svg(render, render_params);
@ -388,25 +442,31 @@ impl GraphicElementRendered for GraphicGroupTable {
#[cfg(feature = "vello")] #[cfg(feature = "vello")]
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) { fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
for instance in self.instance_ref_iter() { let mut iter = self.instance_ref_iter().peekable();
let mut mask_instance_state = None;
while let Some(instance) = iter.next() {
let transform = transform * *instance.transform; let transform = transform * *instance.transform;
let alpha_blending = *instance.alpha_blending; let alpha_blending = *instance.alpha_blending;
let mut layer = false; let mut layer = false;
if let Some(bounds) = self
let bounds = self
.instance_ref_iter() .instance_ref_iter()
.filter_map(|element| element.instance.bounding_box(transform, true)) .filter_map(|element| element.instance.bounding_box(transform, true))
.reduce(Quad::combine_bounds) .reduce(Quad::combine_bounds);
{ if let Some(bounds) = bounds {
let blend_mode = match render_params.view_mode { let blend_mode = match render_params.view_mode {
ViewMode::Outline => peniko::Mix::Normal, ViewMode::Outline => peniko::Mix::Normal,
_ => alpha_blending.blend_mode.into(), _ => alpha_blending.blend_mode.into(),
}; };
if alpha_blending.opacity < 1. || (render_params.view_mode != ViewMode::Outline && alpha_blending.blend_mode != BlendMode::default()) { let factor = if render_params.for_mask { 1. } else { alpha_blending.fill };
let opacity = alpha_blending.opacity * factor;
if opacity < 1. || (render_params.view_mode != ViewMode::Outline && alpha_blending.blend_mode != BlendMode::default()) {
scene.push_layer( scene.push_layer(
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
alpha_blending.opacity, opacity,
kurbo::Affine::IDENTITY, kurbo::Affine::IDENTITY,
&vello::kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y), &vello::kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y),
); );
@ -414,7 +474,33 @@ impl GraphicElementRendered for GraphicGroupTable {
} }
} }
instance.instance.render_to_vello(scene, transform, context, render_params); let next_clips = iter.peek().is_some_and(|next_instance| next_instance.instance.had_clip_enabled());
if next_clips && mask_instance_state.is_none() {
mask_instance_state = Some((instance.instance, transform));
instance.instance.render_to_vello(scene, transform, context, render_params);
} else if let Some((instance_mask, transform_mask)) = mask_instance_state {
if !next_clips {
mask_instance_state = None;
}
if let Some(bounds) = bounds {
let rect = vello::kurbo::Rect::new(bounds[0].x, bounds[0].y, bounds[1].x, bounds[1].y);
scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
instance_mask.render_to_vello(scene, transform_mask, context, &render_params.for_clipper());
scene.push_layer(peniko::BlendMode::new(peniko::Mix::Clip, peniko::Compose::SrcIn), 1., kurbo::Affine::IDENTITY, &rect);
}
instance.instance.render_to_vello(scene, transform, context, render_params);
if bounds.is_some() {
scene.pop_layer();
scene.pop_layer();
}
} else {
instance.instance.render_to_vello(scene, transform, context, render_params);
}
if layer { if layer {
scene.pop_layer(); scene.pop_layer();
@ -488,21 +574,54 @@ impl GraphicElementRendered for GraphicGroupTable {
impl GraphicElementRendered for VectorDataTable { impl GraphicElementRendered for VectorDataTable {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for instance in self.instance_ref_iter() { for instance in self.instance_ref_iter() {
let multiplied_transform = render.transform * *instance.transform; let multiplied_transform = *instance.transform;
let vector_data = &instance.instance;
// Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform // Only consider strokes with non-zero weight, since default strokes with zero weight would prevent assigning the correct stroke transform
let has_real_stroke = instance.instance.style.stroke().filter(|stroke| stroke.weight() > 0.); let has_real_stroke = vector_data.style.stroke().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.);
let applied_stroke_transform = set_stroke_transform.unwrap_or(*instance.transform); let applied_stroke_transform = set_stroke_transform.unwrap_or(*instance.transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse()); let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = instance.instance.bounding_box().unwrap_or_default(); let layer_bounds = vector_data.bounding_box().unwrap_or_default();
let transformed_bounds = instance.instance.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default(); let transformed_bounds = vector_data.bounding_box_with_transform(applied_stroke_transform).unwrap_or_default();
let mut path = String::new(); let mut path = String::new();
for subpath in instance.instance.stroke_bezier_paths() { for subpath in instance.instance.stroke_bezier_paths() {
let _ = subpath.subpath_to_svg(&mut path, applied_stroke_transform); let _ = subpath.subpath_to_svg(&mut path, applied_stroke_transform);
} }
let connected = vector_data.stroke_bezier_paths().all(|path| path.closed());
let can_draw_aligned_stroke = vector_data.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered()) && connected;
let mut push_id = None;
if can_draw_aligned_stroke {
let mask_type = if vector_data.style.stroke().unwrap().align == StrokeAlign::Inside {
MaskType::Clip
} else {
MaskType::Mask
};
let can_use_order = !instance.instance.style.fill().is_none() && mask_type == MaskType::Mask;
if !can_use_order {
let id = format!("alignment-{}", generate_uuid());
let mut vector_row = VectorDataTable::default();
let mut fill_instance = instance.instance.clone();
fill_instance.style.clear_stroke();
fill_instance.style.set_fill(Fill::solid(Color::BLACK));
vector_row.push(Instance {
instance: fill_instance,
alpha_blending: *instance.alpha_blending,
transform: *instance.transform,
source_node_id: None,
});
push_id = Some((id, mask_type, vector_row));
}
}
render.leaf_tag("path", |attributes| { render.leaf_tag("path", |attributes| {
attributes.push("d", path); attributes.push("d", path);
let matrix = format_transform_matrix(element_transform); let matrix = format_transform_matrix(element_transform);
@ -511,15 +630,43 @@ impl GraphicElementRendered for VectorDataTable {
} }
let defs = &mut attributes.0.svg_defs; let defs = &mut attributes.0.svg_defs;
if let Some((ref id, mask_type, ref vector_row)) = push_id {
let mut svg = SvgRender::new();
vector_row.render_svg(&mut svg, &render_params.for_alignment(applied_stroke_transform));
let fill_and_stroke = instance let weight = instance.instance.style.stroke().unwrap().weight * instance.transform.matrix2.determinant();
.instance let quad = Quad::from_box(transformed_bounds).inflate(weight);
.style let (x, y) = quad.top_left().into();
.render(render_params.view_mode, defs, element_transform, applied_stroke_transform, layer_bounds, transformed_bounds); let (width, height) = (quad.bottom_right() - quad.top_left()).into();
write!(defs, r##"{}"##, svg.svg_defs).unwrap();
let rect = format!(r##"<rect x="{}" y="{}" width="{width}" height="{height}" fill="white" />"##, x, y);
match mask_type {
MaskType::Clip => write!(defs, r##"<clipPath id="{id}">{}</clipPath>"##, svg.svg.to_svg_string()).unwrap(),
MaskType::Mask => write!(defs, r##"<mask id="{id}">{}{}</mask>"##, rect, svg.svg.to_svg_string()).unwrap(),
}
}
let fill_and_stroke = instance.instance.style.render(
defs,
element_transform,
applied_stroke_transform,
layer_bounds,
transformed_bounds,
can_draw_aligned_stroke,
can_draw_aligned_stroke && push_id.is_none(),
render_params,
);
if let Some((id, mask_type, _)) = push_id {
let selector = format!("url(#{id})");
attributes.push(mask_type.to_attribute(), selector);
}
attributes.push_val(fill_and_stroke); attributes.push_val(fill_and_stroke);
if instance.alpha_blending.opacity < 1. { let factor = if render_params.for_mask { 1. } else { instance.alpha_blending.fill };
attributes.push("opacity", instance.alpha_blending.opacity.to_string()); let opacity = instance.alpha_blending.opacity * factor;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
} }
if instance.alpha_blending.blend_mode != BlendMode::default() { if instance.alpha_blending.blend_mode != BlendMode::default() {
@ -530,9 +677,9 @@ impl GraphicElementRendered for VectorDataTable {
} }
#[cfg(feature = "vello")] #[cfg(feature = "vello")]
fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _: &mut RenderContext, render_params: &RenderParams) { fn render_to_vello(&self, scene: &mut Scene, parent_transform: DAffine2, _context: &mut RenderContext, render_params: &RenderParams) {
use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT}; use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT};
use crate::vector::style::{GradientType, LineCap, LineJoin}; use crate::vector::style::{GradientType, StrokeCap, StrokeJoin};
use vello::kurbo::{Cap, Join}; use vello::kurbo::{Cap, Join};
use vello::peniko; use vello::peniko;
@ -541,6 +688,7 @@ impl GraphicElementRendered for VectorDataTable {
let has_real_stroke = instance.instance.style.stroke().filter(|stroke| stroke.weight() > 0.); let has_real_stroke = instance.instance.style.stroke().filter(|stroke| stroke.weight() > 0.);
let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.); let set_stroke_transform = has_real_stroke.map(|stroke| stroke.transform).filter(|transform| transform.matrix2.determinant() != 0.);
let applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform); let applied_stroke_transform = set_stroke_transform.unwrap_or(multiplied_transform);
let applied_stroke_transform = render_params.alignment_parent_transform.unwrap_or(applied_stroke_transform);
let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse()); let element_transform = set_stroke_transform.map(|stroke_transform| multiplied_transform * stroke_transform.inverse());
let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY); let element_transform = element_transform.unwrap_or(DAffine2::IDENTITY);
let layer_bounds = instance.instance.bounding_box().unwrap_or_default(); let layer_bounds = instance.instance.bounding_box().unwrap_or_default();
@ -557,16 +705,51 @@ impl GraphicElementRendered for VectorDataTable {
_ => instance.alpha_blending.blend_mode.into(), _ => instance.alpha_blending.blend_mode.into(),
}; };
let mut layer = false; let mut layer = false;
if instance.alpha_blending.opacity < 1. || instance.alpha_blending.blend_mode != BlendMode::default() { let factor = if render_params.for_mask { 1. } else { instance.alpha_blending.fill };
let opacity = instance.alpha_blending.opacity * factor;
if opacity < 1. || instance.alpha_blending.blend_mode != BlendMode::default() {
layer = true; layer = true;
scene.push_layer( scene.push_layer(
peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver), peniko::BlendMode::new(blend_mode, peniko::Compose::SrcOver),
instance.alpha_blending.opacity, opacity,
kurbo::Affine::new(multiplied_transform.to_cols_array()), kurbo::Affine::new(multiplied_transform.to_cols_array()),
&kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y), &kurbo::Rect::new(layer_bounds[0].x, layer_bounds[0].y, layer_bounds[1].x, layer_bounds[1].y),
); );
} }
let can_draw_aligned_stroke = instance.instance.style.stroke().is_some_and(|stroke| stroke.has_renderable_stroke() && stroke.align.is_not_centered())
&& instance.instance.stroke_bezier_paths().all(|path| path.closed());
let reorder_for_outside = instance
.instance
.style
.stroke()
.is_some_and(|stroke| stroke.align == StrokeAlign::Outside && !instance.instance.style.fill().is_none());
if can_draw_aligned_stroke && !reorder_for_outside {
let mut vector_data = VectorDataTable::default();
let mut fill_instance = instance.instance.clone();
fill_instance.style.clear_stroke();
fill_instance.style.set_fill(Fill::solid(Color::BLACK));
vector_data.push(Instance {
instance: fill_instance,
alpha_blending: *instance.alpha_blending,
transform: *instance.transform,
source_node_id: None,
});
let weight = instance.instance.style.stroke().unwrap().weight;
let quad = Quad::from_box(layer_bounds).inflate(weight * element_transform.matrix2.determinant());
let rect = vello::kurbo::Rect::new(quad.top_left().x, quad.top_left().y, quad.bottom_right().x, quad.bottom_right().y);
let inside = instance.instance.style.stroke().unwrap().align == StrokeAlign::Inside;
let compose = if inside { peniko::Compose::SrcIn } else { peniko::Compose::SrcOut };
scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::IDENTITY, &rect);
vector_data.render_to_vello(scene, parent_transform, _context, &render_params.for_alignment(applied_stroke_transform));
scene.push_layer(peniko::BlendMode::new(peniko::Mix::Clip, compose), 1., kurbo::Affine::IDENTITY, &rect);
}
// Render the path // Render the path
match render_params.view_mode { match render_params.view_mode {
ViewMode::Outline => { ViewMode::Outline => {
@ -589,90 +772,111 @@ impl GraphicElementRendered for VectorDataTable {
scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color, None, &path); scene.stroke(&outline_stroke, kurbo::Affine::new(element_transform.to_cols_array()), outline_color, None, &path);
} }
_ => { _ => {
match instance.instance.style.fill() { enum Op {
Fill::Solid(color) => { Fill,
let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])); Stroke,
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, &path); }
}
Fill::Gradient(gradient) => {
let mut stops = peniko::ColorStops::new();
for &(offset, color) in &gradient.stops {
stops.push(peniko::ColorStop {
offset: offset as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
});
}
// Compute bounding box of the shape to determine the gradient start and end points
let bounds = instance.instance.nonzero_bounding_box();
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
let inverse_parent_transform = (parent_transform.matrix2.determinant() != 0.).then(|| parent_transform.inverse()).unwrap_or_default(); let order = match instance.instance.style.stroke().is_some_and(|stroke| !stroke.paint_order.is_default()) || reorder_for_outside {
let mod_points = inverse_parent_transform * multiplied_transform * bound_transform; true => [Op::Stroke, Op::Fill],
false => [Op::Fill, Op::Stroke], // Default
let start = mod_points.transform_point2(gradient.start);
let end = mod_points.transform_point2(gradient.end);
let fill = peniko::Brush::Gradient(peniko::Gradient {
kind: match gradient.gradient_type {
GradientType::Linear => peniko::GradientKind::Linear {
start: to_point(start),
end: to_point(end),
},
GradientType::Radial => {
let radius = start.distance(end);
peniko::GradientKind::Radial {
start_center: to_point(start),
start_radius: 0.,
end_center: to_point(start),
end_radius: radius as f32,
}
}
},
stops,
..Default::default()
});
// Vello does `element_transform * brush_transform` internally. We don't want element_transform to have any impact so we need to left multiply by the inverse.
// This makes the final internal brush transform equal to `parent_transform`, allowing you to stretch a gradient by transforming the parent folder.
let inverse_element_transform = (element_transform.matrix2.determinant() != 0.).then(|| element_transform.inverse()).unwrap_or_default();
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), &path);
}
Fill::None => {}
}; };
for operation in order {
match operation {
Op::Fill => {
match instance.instance.style.fill() {
Fill::Solid(color) => {
let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()]));
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, &path);
}
Fill::Gradient(gradient) => {
let mut stops = peniko::ColorStops::new();
for &(offset, color) in &gradient.stops {
stops.push(peniko::ColorStop {
offset: offset as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
});
}
// Compute bounding box of the shape to determine the gradient start and end points
let bounds = instance.instance.nonzero_bounding_box();
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
if let Some(stroke) = instance.instance.style.stroke() { let inverse_parent_transform = (parent_transform.matrix2.determinant() != 0.).then(|| parent_transform.inverse()).unwrap_or_default();
let color = match stroke.color { let mod_points = inverse_parent_transform * multiplied_transform * bound_transform;
Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]),
None => peniko::Color::TRANSPARENT,
};
let cap = match stroke.line_cap {
LineCap::Butt => Cap::Butt,
LineCap::Round => Cap::Round,
LineCap::Square => Cap::Square,
};
let join = match stroke.line_join {
LineJoin::Miter => Join::Miter,
LineJoin::Bevel => Join::Bevel,
LineJoin::Round => Join::Round,
};
let stroke = kurbo::Stroke {
width: stroke.weight,
miter_limit: stroke.line_join_miter_limit,
join,
start_cap: cap,
end_cap: cap,
dash_pattern: stroke.dash_lengths.into(),
dash_offset: stroke.dash_offset,
};
// Draw the stroke if it's visible let start = mod_points.transform_point2(gradient.start);
if stroke.width > 0. { let end = mod_points.transform_point2(gradient.end);
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path);
let fill = peniko::Brush::Gradient(peniko::Gradient {
kind: match gradient.gradient_type {
GradientType::Linear => peniko::GradientKind::Linear {
start: to_point(start),
end: to_point(end),
},
GradientType::Radial => {
let radius = start.distance(end);
peniko::GradientKind::Radial {
start_center: to_point(start),
start_radius: 0.,
end_center: to_point(start),
end_radius: radius as f32,
}
}
},
stops,
..Default::default()
});
// Vello does `element_transform * brush_transform` internally. We don't want element_transform to have any impact so we need to left multiply by the inverse.
// This makes the final internal brush transform equal to `parent_transform`, allowing you to stretch a gradient by transforming the parent folder.
let inverse_element_transform = (element_transform.matrix2.determinant() != 0.).then(|| element_transform.inverse()).unwrap_or_default();
let brush_transform = kurbo::Affine::new((inverse_element_transform * parent_transform).to_cols_array());
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(element_transform.to_cols_array()), &fill, Some(brush_transform), &path);
}
Fill::None => {}
};
}
Op::Stroke => {
if let Some(stroke) = instance.instance.style.stroke() {
let color = match stroke.color {
Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]),
None => peniko::Color::TRANSPARENT,
};
let cap = match stroke.cap {
StrokeCap::Butt => Cap::Butt,
StrokeCap::Round => Cap::Round,
StrokeCap::Square => Cap::Square,
};
let join = match stroke.join {
StrokeJoin::Miter => Join::Miter,
StrokeJoin::Bevel => Join::Bevel,
StrokeJoin::Round => Join::Round,
};
let stroke = kurbo::Stroke {
width: stroke.weight * if can_draw_aligned_stroke { 2. } else { 1. },
miter_limit: stroke.join_miter_limit,
join,
start_cap: cap,
end_cap: cap,
dash_pattern: stroke.dash_lengths.into(),
dash_offset: stroke.dash_offset,
};
// Draw the stroke if it's visible
if stroke.width > 0. {
scene.stroke(&stroke, kurbo::Affine::new(element_transform.to_cols_array()), color, None, &path);
}
}
}
} }
} }
} }
} }
if can_draw_aligned_stroke {
scene.pop_layer();
scene.pop_layer();
}
// If we pushed a layer for opacity or a blend mode, we need to pop it // If we pushed a layer for opacity or a blend mode, we need to pop it
if layer { if layer {
scene.pop_layer(); scene.pop_layer();
@ -689,11 +893,11 @@ impl GraphicElementRendered for VectorDataTable {
let stroke_width = instance.instance.style.stroke().map(|s| s.weight()).unwrap_or_default(); let stroke_width = instance.instance.style.stroke().map(|s| s.weight()).unwrap_or_default();
let miter_limit = instance.instance.style.stroke().map(|s| s.line_join_miter_limit).unwrap_or(1.); let miter_limit = instance.instance.style.stroke().map(|s| s.join_miter_limit).unwrap_or(1.);
let scale = transform.decompose_scale(); let scale = transform.decompose_scale();
// We use the full line width here to account for different styles of line caps // We use the full line width here to account for different styles of stroke caps
let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit); let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);
instance.instance.bounding_box_with_transform(transform * *instance.transform).map(|[a, b]| [a - offset, b + offset]) instance.instance.bounding_box_with_transform(transform * *instance.transform).map(|[a, b]| [a - offset, b + offset])
@ -844,13 +1048,13 @@ impl GraphicElementRendered for Artboard {
let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]); let color = peniko::Color::new([self.background.r(), self.background.g(), self.background.b(), self.background.a()]);
let [a, b] = [self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()]; let [a, b] = [self.location.as_dvec2(), self.location.as_dvec2() + self.dimensions.as_dvec2()];
let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)); let rect = kurbo::Rect::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y));
let blend_mode = peniko::BlendMode::new(peniko::Mix::Clip, peniko::Compose::SrcOver);
scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect); scene.push_layer(peniko::Mix::Normal, 1., kurbo::Affine::new(transform.to_cols_array()), &rect);
scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect); scene.fill(peniko::Fill::NonZero, kurbo::Affine::new(transform.to_cols_array()), color, None, &rect);
scene.pop_layer(); scene.pop_layer();
if self.clip { if self.clip {
let blend_mode = peniko::BlendMode::new(peniko::Mix::Clip, peniko::Compose::SrcOver);
scene.push_layer(blend_mode, 1., kurbo::Affine::new(transform.to_cols_array()), &rect); scene.push_layer(blend_mode, 1., kurbo::Affine::new(transform.to_cols_array()), &rect);
} }
// Since the graphic group's transform is right multiplied in when rendering the graphic group, we just need to right multiply by the offset here. // Since the graphic group's transform is right multiplied in when rendering the graphic group, we just need to right multiply by the offset here.
@ -935,9 +1139,9 @@ impl GraphicElementRendered for ArtboardGroupTable {
} }
impl GraphicElementRendered for RasterDataTable<CPU> { impl GraphicElementRendered for RasterDataTable<CPU> {
fn render_svg(&self, render: &mut SvgRender, _render_params: &RenderParams) { fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
for instance in self.instance_ref_iter() { for instance in self.instance_ref_iter() {
let transform = *instance.transform * render.transform; let transform = *instance.transform;
let image = &instance.instance; let image = &instance.instance;
if image.data.is_empty() { if image.data.is_empty() {
@ -961,8 +1165,10 @@ impl GraphicElementRendered for RasterDataTable<CPU> {
if !matrix.is_empty() { if !matrix.is_empty() {
attributes.push("transform", matrix); attributes.push("transform", matrix);
} }
if instance.alpha_blending.opacity < 1. { let factor = if render_params.for_mask { 1. } else { instance.alpha_blending.fill };
attributes.push("opacity", instance.alpha_blending.opacity.to_string()); let opacity = instance.alpha_blending.opacity * factor;
if opacity < 1. {
attributes.push("opacity", opacity.to_string());
} }
if instance.alpha_blending.blend_mode != BlendMode::default() { if instance.alpha_blending.blend_mode != BlendMode::default() {
attributes.push("style", instance.alpha_blending.blend_mode.render()); attributes.push("style", instance.alpha_blending.blend_mode.render());

View File

@ -318,6 +318,32 @@ impl SetBlendMode for RasterDataTable<CPU> {
} }
} }
trait SetClip {
fn set_clip(&mut self, clip: bool);
}
impl SetClip for VectorDataTable {
fn set_clip(&mut self, clip: bool) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.clip = clip;
}
}
}
impl SetClip for GraphicGroupTable {
fn set_clip(&mut self, clip: bool) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.clip = clip;
}
}
}
impl SetClip for RasterDataTable<CPU> {
fn set_clip(&mut self, clip: bool) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.clip = clip;
}
}
}
#[node_macro::node(category("Style"))] #[node_macro::node(category("Style"))]
fn blend_mode<T: SetBlendMode>( fn blend_mode<T: SetBlendMode>(
_: impl Ctx, _: impl Ctx,
@ -343,9 +369,31 @@ fn opacity<T: MultiplyAlpha>(
RasterDataTable<CPU>, RasterDataTable<CPU>,
)] )]
mut value: T, mut value: T,
#[default(100.)] factor: Percentage, #[default(100.)] opacity: Percentage,
) -> T { ) -> T {
// TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or Instance<T>) rather than applying to each row in its own table, which produces the undesired result // TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or Instance<T>) rather than applying to each row in its own table, which produces the undesired result
value.multiply_alpha(factor / 100.); value.multiply_alpha(opacity / 100.);
value
}
#[node_macro::node(category("Style"))]
fn blending<T: SetBlendMode + MultiplyAlpha + MultiplyFill + SetClip>(
_: impl Ctx,
#[implementations(
GraphicGroupTable,
VectorDataTable,
RasterDataTable<CPU>,
)]
mut value: T,
blend_mode: BlendMode,
#[default(100.)] opacity: Percentage,
#[default(100.)] fill: Percentage,
#[default(false)] clip: bool,
) -> T {
// TODO: Find a way to make this apply once to the table's parent (i.e. its row in its parent table or Instance<T>) rather than applying to each row in its own table, which produces the undesired result
value.set_blend_mode(blend_mode);
value.multiply_alpha(opacity / 100.);
value.multiply_fill(fill / 100.);
value.set_clip(clip);
value value
} }

View File

@ -1321,6 +1321,36 @@ where
} }
} }
pub(super) trait MultiplyFill {
fn multiply_fill(&mut self, factor: f64);
}
impl MultiplyFill for Color {
fn multiply_fill(&mut self, factor: f64) {
*self = Color::from_rgbaf32_unchecked(self.r(), self.g(), self.b(), (self.a() * factor as f32).clamp(0., 1.))
}
}
impl MultiplyFill for VectorDataTable {
fn multiply_fill(&mut self, factor: f64) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.fill *= factor as f32;
}
}
}
impl MultiplyFill for GraphicGroupTable {
fn multiply_fill(&mut self, factor: f64) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.fill *= factor as f32;
}
}
}
impl MultiplyFill for RasterDataTable<CPU> {
fn multiply_fill(&mut self, factor: f64) {
for instance in self.instance_mut_iter() {
instance.alpha_blending.fill *= factor as f32;
}
}
}
// Aims for interoperable compatibility with: // Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold
// //

View File

@ -514,6 +514,11 @@ impl Color {
self.alpha self.alpha
} }
#[inline(always)]
pub fn is_opaque(&self) -> bool {
self.alpha > 1. - f32::EPSILON
}
#[inline(always)] #[inline(always)]
pub fn average_rgb_channels(&self) -> f32 { pub fn average_rgb_channels(&self) -> f32 {
(self.red + self.green + self.blue) / 3. (self.red + self.green + self.blue) / 3.

View File

@ -2,7 +2,7 @@
use crate::Color; use crate::Color;
use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT}; use crate::consts::{LAYER_OUTLINE_STROKE_COLOR, LAYER_OUTLINE_STROKE_WEIGHT};
use crate::renderer::format_transform_matrix; use crate::renderer::{RenderParams, format_transform_matrix};
use dyn_any::DynAny; use dyn_any::DynAny;
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
use std::fmt::Write; use std::fmt::Write;
@ -214,7 +214,7 @@ impl Gradient {
} }
/// Adds the gradient def through mutating the first argument, returning the gradient ID. /// Adds the gradient def through mutating the first argument, returning the gradient ID.
fn render_defs(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> u64 { fn render_defs(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2], _render_params: &RenderParams) -> u64 {
// TODO: Figure out how to use `self.transform` as part of the gradient transform, since that field (`Gradient::transform`) is currently never read from, it's only written to. // TODO: Figure out how to use `self.transform` as part of the gradient transform, since that field (`Gradient::transform`) is currently never read from, it's only written to.
let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]); let bound_transform = DAffine2::from_scale_angle_translation(bounds[1] - bounds[0], 0., bounds[0]);
@ -381,7 +381,7 @@ impl Fill {
} }
/// Renders the fill, adding necessary defs through mutating the first argument. /// Renders the fill, adding necessary defs through mutating the first argument.
pub fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String { pub fn render(&self, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2], render_params: &RenderParams) -> String {
match self { match self {
Self::None => r#" fill="none""#.to_string(), Self::None => r#" fill="none""#.to_string(),
Self::Solid(color) => { Self::Solid(color) => {
@ -392,7 +392,7 @@ impl Fill {
result result
} }
Self::Gradient(gradient) => { Self::Gradient(gradient) => {
let gradient_id = gradient.render_defs(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds); let gradient_id = gradient.render_defs(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
format!(r##" fill="url('#{gradient_id}')""##) format!(r##" fill="url('#{gradient_id}')""##)
} }
} }
@ -413,6 +413,20 @@ impl Fill {
_ => None, _ => None,
} }
} }
/// Find if fill can be represented with only opaque colors
pub fn is_opaque(&self) -> bool {
match self {
Fill::Solid(color) => color.is_opaque(),
Fill::Gradient(gradient) => gradient.stops.iter().all(|(_, color)| color.is_opaque()),
Fill::None => true,
}
}
/// Returns if fill is none
pub fn is_none(&self) -> bool {
*self == Self::None
}
} }
impl From<Color> for Fill { impl From<Color> for Fill {
@ -499,19 +513,19 @@ pub enum FillType {
#[repr(C)] #[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)] #[widget(Radio)]
pub enum LineCap { pub enum StrokeCap {
#[default] #[default]
Butt, Butt,
Round, Round,
Square, Square,
} }
impl LineCap { impl StrokeCap {
fn svg_name(&self) -> &'static str { fn svg_name(&self) -> &'static str {
match self { match self {
LineCap::Butt => "butt", StrokeCap::Butt => "butt",
LineCap::Round => "round", StrokeCap::Round => "round",
LineCap::Square => "square", StrokeCap::Square => "square",
} }
} }
} }
@ -519,29 +533,61 @@ impl LineCap {
#[repr(C)] #[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)] #[widget(Radio)]
pub enum LineJoin { pub enum StrokeJoin {
#[default] #[default]
Miter, Miter,
Bevel, Bevel,
Round, Round,
} }
impl LineJoin { impl StrokeJoin {
fn svg_name(&self) -> &'static str { fn svg_name(&self) -> &'static str {
match self { match self {
LineJoin::Bevel => "bevel", StrokeJoin::Bevel => "bevel",
LineJoin::Miter => "miter", StrokeJoin::Miter => "miter",
LineJoin::Round => "round", StrokeJoin::Round => "round",
} }
} }
} }
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum StrokeAlign {
#[default]
Center,
Inside,
Outside,
}
impl StrokeAlign {
pub fn is_not_centered(self) -> bool {
self != Self::Center
}
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, specta::Type, node_macro::ChoiceType)]
#[widget(Radio)]
pub enum PaintOrder {
#[default]
StrokeAbove,
StrokeBelow,
}
impl PaintOrder {
pub fn is_default(self) -> bool {
self == Self::default()
}
}
fn daffine2_identity() -> DAffine2 { fn daffine2_identity() -> DAffine2 {
DAffine2::IDENTITY DAffine2::IDENTITY
} }
#[repr(C)] #[repr(C)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny, specta::Type)] #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, DynAny, specta::Type)]
#[serde(default)]
pub struct Stroke { pub struct Stroke {
/// Stroke color /// Stroke color
pub color: Option<Color>, pub color: Option<Color>,
@ -549,26 +595,38 @@ pub struct Stroke {
pub weight: f64, pub weight: f64,
pub dash_lengths: Vec<f64>, pub dash_lengths: Vec<f64>,
pub dash_offset: f64, pub dash_offset: f64,
pub line_cap: LineCap, #[serde(alias = "line_cap")]
pub line_join: LineJoin, pub cap: StrokeCap,
pub line_join_miter_limit: f64, #[serde(alias = "line_join")]
pub join: StrokeJoin,
#[serde(alias = "line_join_miter_limit")]
pub join_miter_limit: f64,
#[serde(default)]
pub align: StrokeAlign,
#[serde(default = "daffine2_identity")] #[serde(default = "daffine2_identity")]
pub transform: DAffine2, pub transform: DAffine2,
#[serde(default)] #[serde(default)]
pub non_scaling: bool, pub non_scaling: bool,
#[serde(default)]
pub paint_order: PaintOrder,
} }
impl core::hash::Hash for Stroke { impl core::hash::Hash for Stroke {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) { fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.color.hash(state); self.color.hash(state);
self.weight.to_bits().hash(state); self.weight.to_bits().hash(state);
self.dash_lengths.len().hash(state); {
self.dash_lengths.iter().for_each(|length| length.to_bits().hash(state)); self.dash_lengths.len().hash(state);
self.dash_lengths.iter().for_each(|length| length.to_bits().hash(state));
}
self.dash_offset.to_bits().hash(state); self.dash_offset.to_bits().hash(state);
self.line_cap.hash(state); self.cap.hash(state);
self.line_join.hash(state); self.join.hash(state);
self.line_join_miter_limit.to_bits().hash(state); self.join_miter_limit.to_bits().hash(state);
self.align.hash(state);
self.transform.to_cols_array().iter().for_each(|x| x.to_bits().hash(state));
self.non_scaling.hash(state); self.non_scaling.hash(state);
self.paint_order.hash(state);
} }
} }
@ -590,11 +648,13 @@ impl Stroke {
weight, weight,
dash_lengths: Vec::new(), dash_lengths: Vec::new(),
dash_offset: 0., dash_offset: 0.,
line_cap: LineCap::Butt, cap: StrokeCap::Butt,
line_join: LineJoin::Miter, join: StrokeJoin::Miter,
line_join_miter_limit: 4., join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
non_scaling: false, non_scaling: false,
paint_order: PaintOrder::StrokeAbove,
} }
} }
@ -604,14 +664,16 @@ impl Stroke {
weight: self.weight + (other.weight - self.weight) * time, weight: self.weight + (other.weight - self.weight) * time,
dash_lengths: self.dash_lengths.iter().zip(other.dash_lengths.iter()).map(|(a, b)| a + (b - a) * time).collect(), dash_lengths: self.dash_lengths.iter().zip(other.dash_lengths.iter()).map(|(a, b)| a + (b - a) * time).collect(),
dash_offset: self.dash_offset + (other.dash_offset - self.dash_offset) * time, dash_offset: self.dash_offset + (other.dash_offset - self.dash_offset) * time,
line_cap: if time < 0.5 { self.line_cap } else { other.line_cap }, cap: if time < 0.5 { self.cap } else { other.cap },
line_join: if time < 0.5 { self.line_join } else { other.line_join }, join: if time < 0.5 { self.join } else { other.join },
line_join_miter_limit: self.line_join_miter_limit + (other.line_join_miter_limit - self.line_join_miter_limit) * time, join_miter_limit: self.join_miter_limit + (other.join_miter_limit - self.join_miter_limit) * time,
align: if time < 0.5 { self.align } else { other.align },
transform: DAffine2::from_mat2_translation( transform: DAffine2::from_mat2_translation(
time * self.transform.matrix2 + (1. - time) * other.transform.matrix2, time * self.transform.matrix2 + (1. - time) * other.transform.matrix2,
self.transform.translation * time + other.transform.translation * (1. - time), self.transform.translation * time + other.transform.translation * (1. - time),
), ),
non_scaling: if time < 0.5 { self.non_scaling } else { other.non_scaling }, non_scaling: if time < 0.5 { self.non_scaling } else { other.non_scaling },
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
} }
} }
@ -637,23 +699,23 @@ impl Stroke {
self.dash_offset self.dash_offset
} }
pub fn line_cap_index(&self) -> u32 { pub fn cap_index(&self) -> u32 {
self.line_cap as u32 self.cap as u32
} }
pub fn line_join_index(&self) -> u32 { pub fn join_index(&self) -> u32 {
self.line_join as u32 self.join as u32
} }
pub fn line_join_miter_limit(&self) -> f32 { pub fn join_miter_limit(&self) -> f32 {
self.line_join_miter_limit as f32 self.join_miter_limit as f32
} }
/// Provide the SVG attributes for the stroke. /// Provide the SVG attributes for the stroke.
pub fn render(&self) -> String { pub fn render(&self, aligned_strokes: bool, override_paint_order: bool, _render_params: &RenderParams) -> String {
// Don't render a stroke at all if it would be invisible // Don't render a stroke at all if it would be invisible
let Some(color) = self.color else { return String::new() }; let Some(color) = self.color else { return String::new() };
if self.weight <= 0. || color.a() == 0. { if !self.has_renderable_stroke() {
return String::new(); return String::new();
} }
@ -661,16 +723,21 @@ impl Stroke {
let weight = (self.weight != 1.).then_some(self.weight); let weight = (self.weight != 1.).then_some(self.weight);
let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths()); let dash_array = (!self.dash_lengths.is_empty()).then_some(self.dash_lengths());
let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset); let dash_offset = (self.dash_offset != 0.).then_some(self.dash_offset);
let line_cap = (self.line_cap != LineCap::Butt).then_some(self.line_cap); let stroke_cap = (self.cap != StrokeCap::Butt).then_some(self.cap);
let line_join = (self.line_join != LineJoin::Miter).then_some(self.line_join); let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let line_join_miter_limit = (self.line_join_miter_limit != 4.).then_some(self.line_join_miter_limit); let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes // Render the needed stroke attributes
let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma()); let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma());
if color.a() < 1. { if color.a() < 1. {
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.); let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
} }
if let Some(weight) = weight { if let Some(mut weight) = weight {
if stroke_align.is_some() && aligned_strokes {
weight *= 2.;
}
let _ = write!(&mut attributes, r#" stroke-width="{}""#, weight); let _ = write!(&mut attributes, r#" stroke-width="{}""#, weight);
} }
if let Some(dash_array) = dash_array { if let Some(dash_array) = dash_array {
@ -679,19 +746,22 @@ impl Stroke {
if let Some(dash_offset) = dash_offset { if let Some(dash_offset) = dash_offset {
let _ = write!(&mut attributes, r#" stroke-dashoffset="{}""#, dash_offset); let _ = write!(&mut attributes, r#" stroke-dashoffset="{}""#, dash_offset);
} }
if let Some(line_cap) = line_cap { if let Some(stroke_cap) = stroke_cap {
let _ = write!(&mut attributes, r#" stroke-linecap="{}""#, line_cap.svg_name()); let _ = write!(&mut attributes, r#" stroke-linecap="{}""#, stroke_cap.svg_name());
} }
if let Some(line_join) = line_join { if let Some(stroke_join) = stroke_join {
let _ = write!(&mut attributes, r#" stroke-linejoin="{}""#, line_join.svg_name()); let _ = write!(&mut attributes, r#" stroke-linejoin="{}""#, stroke_join.svg_name());
} }
if let Some(line_join_miter_limit) = line_join_miter_limit { if let Some(stroke_join_miter_limit) = stroke_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{}""#, line_join_miter_limit); let _ = write!(&mut attributes, r#" stroke-miterlimit="{}""#, stroke_join_miter_limit);
} }
// Add vector-effect attribute to make strokes non-scaling // Add vector-effect attribute to make strokes non-scaling
if self.non_scaling { if self.non_scaling {
let _ = write!(&mut attributes, r#" vector-effect="non-scaling-stroke""#); let _ = write!(&mut attributes, r#" vector-effect="non-scaling-stroke""#);
} }
if paint_order.is_some() {
let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#);
}
attributes attributes
} }
@ -724,18 +794,23 @@ impl Stroke {
self self
} }
pub fn with_line_cap(mut self, line_cap: LineCap) -> Self { pub fn with_stroke_cap(mut self, stroke_cap: StrokeCap) -> Self {
self.line_cap = line_cap; self.cap = stroke_cap;
self self
} }
pub fn with_line_join(mut self, line_join: LineJoin) -> Self { pub fn with_stroke_join(mut self, stroke_join: StrokeJoin) -> Self {
self.line_join = line_join; self.join = stroke_join;
self self
} }
pub fn with_line_join_miter_limit(mut self, limit: f64) -> Self { pub fn with_stroke_join_miter_limit(mut self, limit: f64) -> Self {
self.line_join_miter_limit = limit; self.join_miter_limit = limit;
self
}
pub fn with_stroke_align(mut self, stroke_align: StrokeAlign) -> Self {
self.align = stroke_align;
self self
} }
@ -743,6 +818,10 @@ impl Stroke {
self.non_scaling = non_scaling; self.non_scaling = non_scaling;
self self
} }
pub fn has_renderable_stroke(&self) -> bool {
self.weight > 0. && self.color.is_some_and(|color| color.a() != 0.)
}
} }
// Having an alpha of 1 to start with leads to a better experience with the properties panel // Having an alpha of 1 to start with leads to a better experience with the properties panel
@ -753,11 +832,13 @@ impl Default for Stroke {
color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)), color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)),
dash_lengths: Vec::new(), dash_lengths: Vec::new(),
dash_offset: 0., dash_offset: 0.,
line_cap: LineCap::Butt, cap: StrokeCap::Butt,
line_join: LineJoin::Miter, join: StrokeJoin::Miter,
line_join_miter_limit: 4., join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
non_scaling: false, non_scaling: false,
paint_order: PaintOrder::default(),
} }
} }
} }
@ -929,19 +1010,35 @@ impl PathStyle {
} }
/// Renders the shape's fill and stroke attributes as a string with them concatenated together. /// Renders the shape's fill and stroke attributes as a string with them concatenated together.
pub fn render(&self, view_mode: ViewMode, svg_defs: &mut String, element_transform: DAffine2, stroke_transform: DAffine2, bounds: [DVec2; 2], transformed_bounds: [DVec2; 2]) -> String { #[allow(clippy::too_many_arguments)]
pub fn render(
&self,
svg_defs: &mut String,
element_transform: DAffine2,
stroke_transform: DAffine2,
bounds: [DVec2; 2],
transformed_bounds: [DVec2; 2],
aligned_strokes: bool,
override_paint_order: bool,
render_params: &RenderParams,
) -> String {
let view_mode = render_params.view_mode;
match view_mode { match view_mode {
ViewMode::Outline => { ViewMode::Outline => {
let fill_attribute = Fill::None.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds); let fill_attribute = Fill::None.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
let mut outline_stroke = Stroke::new(Some(LAYER_OUTLINE_STROKE_COLOR), LAYER_OUTLINE_STROKE_WEIGHT); let mut outline_stroke = Stroke::new(Some(LAYER_OUTLINE_STROKE_COLOR), LAYER_OUTLINE_STROKE_WEIGHT);
// Outline strokes should be non-scaling by default // Outline strokes should be non-scaling by default
outline_stroke.non_scaling = true; outline_stroke.non_scaling = true;
let stroke_attribute = outline_stroke.render(); let stroke_attribute = outline_stroke.render(aligned_strokes, override_paint_order, render_params);
format!("{fill_attribute}{stroke_attribute}") format!("{fill_attribute}{stroke_attribute}")
} }
_ => { _ => {
let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds); let fill_attribute = self.fill.render(svg_defs, element_transform, stroke_transform, bounds, transformed_bounds, render_params);
let stroke_attribute = self.stroke.as_ref().map(|stroke| stroke.render()).unwrap_or_default(); let stroke_attribute = self
.stroke
.as_ref()
.map(|stroke| stroke.render(aligned_strokes, override_paint_order, render_params))
.unwrap_or_default();
format!("{fill_attribute}{stroke_attribute}") format!("{fill_attribute}{stroke_attribute}")
} }
} }

View File

@ -10,7 +10,7 @@ use crate::registry::types::{Angle, Fraction, IntegerCount, Length, Multiplier,
use crate::renderer::GraphicElementRendered; use crate::renderer::GraphicElementRendered;
use crate::transform::{Footprint, ReferencePoint, Transform}; use crate::transform::{Footprint, ReferencePoint, Transform};
use crate::vector::misc::dvec2_to_point; use crate::vector::misc::dvec2_to_point;
use crate::vector::style::{LineCap, LineJoin}; use crate::vector::style::{PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use crate::vector::{FillId, PointDomain, RegionId}; use crate::vector::{FillId, PointDomain, RegionId};
use crate::{CloneVarArgs, Color, Context, Ctx, ExtractAll, GraphicElement, GraphicGroupTable, OwnedContextImpl}; use crate::{CloneVarArgs, Color, Context, Ctx, ExtractAll, GraphicElement, GraphicGroupTable, OwnedContextImpl};
use bezier_rs::{Join, ManipulatorGroup, Subpath}; use bezier_rs::{Join, ManipulatorGroup, Subpath};
@ -167,17 +167,22 @@ async fn stroke<C: Into<Option<Color>> + 'n + Send, V>(
#[default(2.)] #[default(2.)]
/// The stroke weight. /// The stroke weight.
weight: f64, weight: f64,
/// 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. /// The alignment of stroke to the path's centerline or (for closed shapes) the inside or outside of the shape.
dash_lengths: Vec<f64>, align: StrokeAlign,
/// The offset distance from the starting point of the dash pattern.
dash_offset: f64,
/// The shape of the stroke at open endpoints. /// The shape of the stroke at open endpoints.
line_cap: crate::vector::style::LineCap, cap: StrokeCap,
/// The curvature of the bent stroke at sharp corners. /// The curvature of the bent stroke at sharp corners.
line_join: LineJoin, join: StrokeJoin,
#[default(4.)] #[default(4.)]
/// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio. /// The threshold for when a miter-joined stroke is converted to a bevel-joined stroke when a sharp angle becomes pointier than this ratio.
miter_limit: f64, miter_limit: f64,
/// The order to paint the stroke on top of the fill, or the fill on top of the stroke.
/// <https://svgwg.org/svg2-draft/painting.html#PaintOrderProperty>
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: Vec<f64>,
/// The phase offset distance from the starting point of the dash pattern.
dash_offset: f64,
) -> Instances<V> ) -> Instances<V>
where where
Instances<V>: VectorDataTableIterMut + 'n + Send, Instances<V>: VectorDataTableIterMut + 'n + Send,
@ -187,12 +192,15 @@ where
weight, weight,
dash_lengths, dash_lengths,
dash_offset, dash_offset,
line_cap, cap,
line_join, join,
line_join_miter_limit: miter_limit, join_miter_limit: miter_limit,
align,
transform: DAffine2::IDENTITY, transform: DAffine2::IDENTITY,
non_scaling: false, non_scaling: false,
paint_order,
}; };
for vector in vector_data.vector_iter_mut() { for vector in vector_data.vector_iter_mut() {
let mut stroke = stroke.clone(); let mut stroke = stroke.clone();
stroke.transform *= *vector.transform; stroke.transform *= *vector.transform;
@ -1084,7 +1092,7 @@ async fn points_to_polyline(_: impl Ctx, mut points: VectorDataTable, #[default(
} }
#[node_macro::node(category("Vector"), path(graphene_core::vector), properties("offset_path_properties"))] #[node_macro::node(category("Vector"), path(graphene_core::vector), properties("offset_path_properties"))]
async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, line_join: LineJoin, #[default(4.)] miter_limit: f64) -> VectorDataTable { async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, join: StrokeJoin, #[default(4.)] miter_limit: f64) -> VectorDataTable {
let mut result_table = VectorDataTable::default(); let mut result_table = VectorDataTable::default();
for mut vector_data_instance in vector_data.instance_iter() { for mut vector_data_instance in vector_data.instance_iter() {
@ -1106,10 +1114,10 @@ async fn offset_path(_: impl Ctx, vector_data: VectorDataTable, distance: f64, l
let mut subpath_out = offset_subpath( let mut subpath_out = offset_subpath(
&subpath, &subpath,
-distance, -distance,
match line_join { match join {
LineJoin::Miter => Join::Miter(Some(miter_limit)), StrokeJoin::Miter => Join::Miter(Some(miter_limit)),
LineJoin::Bevel => Join::Bevel, StrokeJoin::Bevel => Join::Bevel,
LineJoin::Round => Join::Round, StrokeJoin::Round => Join::Round,
}, },
); );
@ -1139,19 +1147,19 @@ async fn solidify_stroke(_: impl Ctx, vector_data: VectorDataTable) -> VectorDat
let mut result = VectorData::default(); let mut result = VectorData::default();
// Taking the existing stroke data and passing it to kurbo::stroke to generate new fill paths. // Taking the existing stroke data and passing it to kurbo::stroke to generate new fill paths.
let join = match stroke.line_join { let join = match stroke.join {
LineJoin::Miter => kurbo::Join::Miter, StrokeJoin::Miter => kurbo::Join::Miter,
LineJoin::Bevel => kurbo::Join::Bevel, StrokeJoin::Bevel => kurbo::Join::Bevel,
LineJoin::Round => kurbo::Join::Round, StrokeJoin::Round => kurbo::Join::Round,
}; };
let cap = match stroke.line_cap { let cap = match stroke.cap {
LineCap::Butt => kurbo::Cap::Butt, StrokeCap::Butt => kurbo::Cap::Butt,
LineCap::Round => kurbo::Cap::Round, StrokeCap::Round => kurbo::Cap::Round,
LineCap::Square => kurbo::Cap::Square, StrokeCap::Square => kurbo::Cap::Square,
}; };
let dash_offset = stroke.dash_offset; let dash_offset = stroke.dash_offset;
let dash_pattern = stroke.dash_lengths; let dash_pattern = stroke.dash_lengths;
let miter_limit = stroke.line_join_miter_limit; let miter_limit = stroke.join_miter_limit;
let stroke_style = kurbo::Stroke::new(stroke.weight) let stroke_style = kurbo::Stroke::new(stroke.weight)
.with_caps(cap) .with_caps(cap)

View File

@ -234,8 +234,12 @@ tagged_value! {
SelectiveColorChoice(graphene_core::raster::SelectiveColorChoice), SelectiveColorChoice(graphene_core::raster::SelectiveColorChoice),
GridType(graphene_core::vector::misc::GridType), GridType(graphene_core::vector::misc::GridType),
ArcType(graphene_core::vector::misc::ArcType), ArcType(graphene_core::vector::misc::ArcType),
LineCap(graphene_core::vector::style::LineCap), #[serde(alias = "LineCap")]
LineJoin(graphene_core::vector::style::LineJoin), StrokeCap(graphene_core::vector::style::StrokeCap),
#[serde(alias = "LineJoin")]
StrokeJoin(graphene_core::vector::style::StrokeJoin),
StrokeAlign(graphene_core::vector::style::StrokeAlign),
PaintOrder(graphene_core::vector::style::PaintOrder),
FillType(graphene_core::vector::style::FillType), FillType(graphene_core::vector::style::FillType),
FillChoice(graphene_core::vector::style::FillChoice), FillChoice(graphene_core::vector::style::FillChoice),
GradientType(graphene_core::vector::style::GradientType), GradientType(graphene_core::vector::style::GradientType),

View File

@ -259,7 +259,15 @@ async fn render<'a: 'n, T: 'n + GraphicElementRendered + WasmNotSend>(
ctx.footprint(); ctx.footprint();
let RenderConfig { hide_artboards, for_export, .. } = render_config; let RenderConfig { hide_artboards, for_export, .. } = render_config;
let render_params = RenderParams::new(render_config.view_mode, None, false, hide_artboards, for_export); let render_params = RenderParams {
view_mode: render_config.view_mode,
culling_bounds: None,
thumbnail: false,
hide_artboards,
for_export,
for_mask: false,
alignment_parent_transform: None,
};
let data = data.eval(ctx.clone()).await; let data = data.eval(ctx.clone()).await;
let editor_api = editor_api.eval(None).await; let editor_api = editor_api.eval(None).await;

View File

@ -61,8 +61,10 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::misc::BooleanOperation]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::misc::BooleanOperation]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Option<graphene_core::Color>]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => Option<graphene_core::Color>]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Fill]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Fill]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::LineCap]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::StrokeCap]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::LineJoin]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::StrokeJoin]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::PaintOrder]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::StrokeAlign]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Stroke]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Stroke]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Gradient]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::Gradient]),
async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::GradientStops]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_core::vector::style::GradientStops]),