diff --git a/.branding b/.branding index 1e29c19dff..300002012c 100644 --- a/.branding +++ b/.branding @@ -1,2 +1,2 @@ -https://github.com/Keavon/graphite-branded-assets/archive/8ae15dc9c51a3855475d8cab1d0f29d9d9bc622c.tar.gz -c19abe4ac848f3c835e43dc065c59e20e60233ae023ea0a064c5fed442be2d3d +https://github.com/Keavon/graphite-branded-assets/archive/fc02baf37d7428e86aa0e5f772c1f6e42173d405.tar.gz +061a880c753472ea0a84fbc09e54d3596bea6e299067169d34347c717de13ded diff --git a/editor/src/consts.rs b/editor/src/consts.rs index 78b3a128fc..645ab7ce46 100644 --- a/editor/src/consts.rs +++ b/editor/src/consts.rs @@ -186,3 +186,6 @@ pub const DOUBLE_CLICK_MILLISECONDS: u64 = 500; pub const UI_SCALE_DEFAULT: f64 = 1.; pub const UI_SCALE_MIN: f64 = 0.5; pub const UI_SCALE_MAX: f64 = 3.; + +// ACTIONS +pub const BLEND_COUNT_PER_LAYER: usize = 10; diff --git a/editor/src/messages/input_mapper/input_mappings.rs b/editor/src/messages/input_mapper/input_mappings.rs index b9bb234dc9..2d9669a355 100644 --- a/editor/src/messages/input_mapper/input_mappings.rs +++ b/editor/src/messages/input_mapper/input_mappings.rs @@ -357,6 +357,8 @@ pub fn input_mappings(zoom_with_scroll: bool) -> Mapping { entry!(KeyDown(KeyS); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::SaveDocumentAs), entry!(KeyDown(KeyD); modifiers=[Accel], canonical, action_dispatch=DocumentMessage::DuplicateSelectedLayers), entry!(KeyDown(KeyJ); modifiers=[Accel], action_dispatch=DocumentMessage::DuplicateSelectedLayers), + entry!(KeyDown(KeyB); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::BlendSelectedLayers), + entry!(KeyDown(KeyM); modifiers=[Accel, Alt], action_dispatch=DocumentMessage::MorphSelectedLayers), // Might get eaten by the GeForce Experience overlay for some Windows users entry!(KeyDown(KeyG); modifiers=[Accel], action_dispatch=DocumentMessage::GroupSelectedLayers { group_folder_type: GroupFolderType::Layer }), entry!(KeyDown(KeyG); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::UngroupSelectedLayers), entry!(KeyDown(KeyN); modifiers=[Accel, Shift], action_dispatch=DocumentMessage::CreateEmptyFolder), diff --git a/editor/src/messages/menu_bar/menu_bar_message_handler.rs b/editor/src/messages/menu_bar/menu_bar_message_handler.rs index 8d7adeb73a..1b620ea7d2 100644 --- a/editor/src/messages/menu_bar/menu_bar_message_handler.rs +++ b/editor/src/messages/menu_bar/menu_bar_message_handler.rs @@ -495,6 +495,18 @@ impl LayoutHolder for MenuBarMessageHandler { }) .disabled(no_active_document || !has_selected_layers), ]]), + MenuListEntry::new("Blend") + .label("Blend") + .icon("InterpolationBlend") + .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::BlendSelectedLayers)) + .on_commit(|_| DocumentMessage::BlendSelectedLayers.into()) + .disabled(no_active_document || !has_selected_layers), + MenuListEntry::new("Morph") + .label("Morph") + .icon("InterpolationMorph") + .tooltip_shortcut(action_shortcut!(DocumentMessageDiscriminant::MorphSelectedLayers)) + .on_commit(|_| DocumentMessage::MorphSelectedLayers.into()) + .disabled(no_active_document || !has_selected_layers), ], vec![ MenuListEntry::new("Make Path Editable") diff --git a/editor/src/messages/portfolio/document/document_message.rs b/editor/src/messages/portfolio/document/document_message.rs index babe195b5f..8da43068a0 100644 --- a/editor/src/messages/portfolio/document/document_message.rs +++ b/editor/src/messages/portfolio/document/document_message.rs @@ -84,6 +84,8 @@ pub enum DocumentMessage { GridVisibility { visible: bool, }, + BlendSelectedLayers, + MorphSelectedLayers, GroupSelectedLayers { group_folder_type: GroupFolderType, }, diff --git a/editor/src/messages/portfolio/document/document_message_handler.rs b/editor/src/messages/portfolio/document/document_message_handler.rs index 68b79a8d7f..4ace922df0 100644 --- a/editor/src/messages/portfolio/document/document_message_handler.rs +++ b/editor/src/messages/portfolio/document/document_message_handler.rs @@ -5,7 +5,8 @@ use super::utility_types::network_interface::{self, NodeNetworkInterface, Transa use super::utility_types::nodes::{CollapsedLayers, LayerStructureEntry, SelectedNodes}; use crate::application::{GRAPHITE_GIT_COMMIT_HASH, generate_uuid}; use crate::consts::{ - ASYMPTOTIC_EFFECT, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, VIEWPORT_ROTATE_SNAP_INTERVAL, + ASYMPTOTIC_EFFECT, BLEND_COUNT_PER_LAYER, COLOR_OVERLAY_GRAY, DEFAULT_DOCUMENT_NAME, FILE_EXTENSION, LAYER_INDENT_OFFSET, NODE_CHAIN_WIDTH, SCALE_EFFECT, SCROLLBAR_SPACING, + VIEWPORT_ROTATE_SNAP_INTERVAL, }; use crate::messages::input_mapper::utility_types::macros::action_shortcut; use crate::messages::layout::utility_types::widget_prelude::*; @@ -624,6 +625,12 @@ impl MessageHandler> for DocumentMes self.snapping_state.grid_snapping = visible; responses.add(OverlaysMessage::Draw); } + DocumentMessage::BlendSelectedLayers => { + self.handle_group_selected_layers(GroupFolderType::Blend, responses); + } + DocumentMessage::MorphSelectedLayers => { + self.handle_group_selected_layers(GroupFolderType::Morph, responses); + } DocumentMessage::GroupSelectedLayers { group_folder_type } => { self.handle_group_selected_layers(group_folder_type, responses); } @@ -1482,6 +1489,8 @@ impl MessageHandler> for DocumentMes DeleteSelectedLayers, DuplicateSelectedLayers, GroupSelectedLayers, + BlendSelectedLayers, + MorphSelectedLayers, SelectedLayersLower, SelectedLayersLowerToBack, SelectedLayersRaise, @@ -1961,6 +1970,43 @@ impl DocumentMessageHandler { }); } } + GroupFolderType::Blend | GroupFolderType::Morph => { + let control_path_id = NodeId(generate_uuid()); + let all_layers_to_group = network_interface.shallowest_unique_layers_sorted(&[]); + let blend_count = matches!(group_folder_type, GroupFolderType::Blend).then(|| all_layers_to_group.len() * BLEND_COUNT_PER_LAYER); + + responses.add(GraphOperationMessage::NewInterpolationLayer { + id: folder_id, + control_path_id, + parent, + insert_index, + blend_count, + }); + + let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); + + // Move selected layers into the group as children + for layer_to_group in all_layers_to_group.into_iter().rev() { + responses.add(NodeGraphMessage::MoveLayerToStack { + layer: layer_to_group, + parent: new_group_folder, + insert_index: 0, + }); + } + + // Connect the child stack to the control path layer as a co-parent + responses.add(GraphOperationMessage::ConnectInterpolationControlPathToChildren { + interpolation_layer_id: folder_id, + control_path_id, + }); + + responses.add(NodeGraphMessage::SelectedNodesSet { nodes: vec![folder_id] }); + responses.add(NodeGraphMessage::RunDocumentGraph); + responses.add(DocumentMessage::DocumentStructureChanged); + responses.add(NodeGraphMessage::SendGraph); + + return folder_id; + } } let new_group_folder = LayerNodeIdentifier::new_unchecked(folder_id); diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs index 4d94f713ba..14b7dfd66b 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message.rs @@ -74,6 +74,17 @@ pub enum GraphOperationMessage { parent: LayerNodeIdentifier, insert_index: usize, }, + NewInterpolationLayer { + id: NodeId, + control_path_id: NodeId, + parent: LayerNodeIdentifier, + insert_index: usize, + blend_count: Option, + }, + ConnectInterpolationControlPathToChildren { + interpolation_layer_id: NodeId, + control_path_id: NodeId, + }, NewBooleanOperationLayer { id: NodeId, operation: graphene_std::vector::misc::BooleanOperation, diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 03fc166dd4..991d644a78 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -172,6 +172,77 @@ impl MessageHandler> for network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); responses.add(NodeGraphMessage::RunDocumentGraph); } + GraphOperationMessage::NewInterpolationLayer { + id, + control_path_id, + parent, + insert_index, + blend_count, + } => { + let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); + let layer = modify_inputs.create_layer(id); + + // Insert the main chain node (Blend or Morph) depending on whether a blend count is provided + let (chain_node_id, layer_alias, path_alias) = if let Some(count) = blend_count { + (modify_inputs.insert_blend_data(layer, count as f64), "Blend", "Blend Path") + } else { + (modify_inputs.insert_morph_data(layer), "Morph", "Morph Path") + }; + + // Create the control path layer (Path → Auto-Tangents → Origins to Polyline) + let control_path_layer = modify_inputs.create_layer(control_path_id); + let path_node_id = modify_inputs.insert_control_path_data(control_path_layer); + + network_interface.move_layer_to_stack(layer, parent, insert_index, &[]); + network_interface.move_layer_to_stack(control_path_layer, parent, insert_index + 1, &[]); + + // Connect the Path node's output to the chain node's path parameter input (input 4 for both Morph and Blend). + // Done after move_layer_to_stack so chain nodes have correct positions when converted to absolute. + network_interface.set_input(&InputConnector::node(chain_node_id, 4), NodeInput::node(path_node_id, 0), &[]); + + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id: id, + alias: layer_alias.to_string(), + }); + responses.add(NodeGraphMessage::SetDisplayNameImpl { + node_id: control_path_id, + alias: path_alias.to_string(), + }); + } + GraphOperationMessage::ConnectInterpolationControlPathToChildren { + interpolation_layer_id, + control_path_id, + } => { + // Find the chain node (Blend or Morph, first in chain of the layer) + let Some(OutputConnector::Node { node_id: chain_node, .. }) = network_interface.upstream_output_connector(&InputConnector::node(interpolation_layer_id, 1), &[]) else { + log::error!("Could not find chain node for layer {interpolation_layer_id}"); + return; + }; + + // Get what feeds into the chain node's primary input (the children stack) + let Some(OutputConnector::Node { node_id: children_id, output_index }) = network_interface.upstream_output_connector(&InputConnector::node(chain_node, 0), &[]) else { + log::error!("Could not find children stack feeding chain node {chain_node}"); + return; + }; + + // Find the deepest node in the control path layer's chain (Origins to Polyline) + let mut deepest_chain_node = None; + let mut current_connector = InputConnector::node(control_path_id, 1); + while let Some(OutputConnector::Node { node_id, .. }) = network_interface.upstream_output_connector(¤t_connector, &[]) { + deepest_chain_node = Some(node_id); + current_connector = InputConnector::node(node_id, 0); + } + + // Connect children to the deepest chain node's input 0 (or the layer's input 1 if no chain) + let target_connector = match deepest_chain_node { + Some(node_id) => InputConnector::node(node_id, 0), + None => InputConnector::node(control_path_id, 1), + }; + network_interface.set_input(&target_connector, NodeInput::node(children_id, output_index), &[]); + + // Shift the child stack (topmost child only, the rest follow) down 3 and left 10 + network_interface.shift_node(&children_id, IVec2::new(-10, 3), &[]); + } GraphOperationMessage::NewBooleanOperationLayer { id, operation, parent, insert_index } => { let mut modify_inputs = ModifyInputsContext::new(network_interface, responses); let layer = modify_inputs.create_layer(id); diff --git a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs index ca92e7134d..605e2efa59 100644 --- a/editor/src/messages/portfolio/document/graph_operation/utility_types.rs +++ b/editor/src/messages/portfolio/document/graph_operation/utility_types.rs @@ -156,6 +156,64 @@ impl<'a> ModifyInputsContext<'a> { self.network_interface.move_node_to_chain_start(&boolean_id, layer, &[], self.import); } + pub fn insert_blend_data(&mut self, layer: LayerNodeIdentifier, count: f64) -> NodeId { + let blend = resolve_network_node_type("Blend").expect("Blend node does not exist").node_template_input_override([ + Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), + Some(NodeInput::value(TaggedValue::F64(count), false)), + ]); + + let blend_id = NodeId::new(); + self.network_interface.insert_node(blend_id, blend, &[]); + self.network_interface.move_node_to_chain_start(&blend_id, layer, &[], self.import); + + blend_id + } + + pub fn insert_morph_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { + let morph = resolve_proto_node_type(graphene_std::vector::morph::IDENTIFIER) + .expect("Morph node does not exist") + .node_template_input_override([ + Some(NodeInput::value(TaggedValue::Graphic(Default::default()), true)), + Some(NodeInput::value(TaggedValue::F64(0.5), false)), + None, + None, + Some(NodeInput::value(TaggedValue::Vector(Default::default()), false)), + ]); + + let morph_id = NodeId::new(); + self.network_interface.insert_node(morph_id, morph, &[]); + self.network_interface.move_node_to_chain_start(&morph_id, layer, &[], self.import); + + morph_id + } + + /// Returns the Path node ID (the node closest to the layer's merge node in the chain). + pub fn insert_control_path_data(&mut self, layer: LayerNodeIdentifier) -> NodeId { + // Add Origins to Polyline node first (will be pushed deepest in the chain) + let origins_to_polyline = resolve_network_node_type("Origins to Polyline") + .expect("Origins to Polyline node does not exist") + .default_node_template(); + let origins_to_polyline_id = NodeId::new(); + self.network_interface.insert_node(origins_to_polyline_id, origins_to_polyline, &[]); + self.network_interface.move_node_to_chain_start(&origins_to_polyline_id, layer, &[], self.import); + + // Add Auto-Tangents node (between Origins to Polyline and Path), with spread=1 and preserve_existing=false + let auto_tangents = resolve_proto_node_type(graphene_std::vector::auto_tangents::IDENTIFIER) + .expect("Auto-Tangents node does not exist") + .node_template_input_override([None, Some(NodeInput::value(TaggedValue::F64(1.), false)), Some(NodeInput::value(TaggedValue::Bool(false), false))]); + let auto_tangents_id = NodeId::new(); + self.network_interface.insert_node(auto_tangents_id, auto_tangents, &[]); + self.network_interface.move_node_to_chain_start(&auto_tangents_id, layer, &[], self.import); + + // Add Path node to chain start (closest to the Merge node) + let path = resolve_network_node_type("Path").expect("Path node does not exist").default_node_template(); + let path_id = NodeId::new(); + self.network_interface.insert_node(path_id, path, &[]); + self.network_interface.move_node_to_chain_start(&path_id, layer, &[], self.import); + + path_id + } + pub fn insert_vector(&mut self, subpaths: Vec>, layer: LayerNodeIdentifier, include_transform: bool, include_fill: bool, include_stroke: bool) { let vector = Table::new_from_element(Vector::from_subpaths(subpaths, true)); diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs index 7c2c1d0d6c..a319cc0e57 100644 --- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs +++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs @@ -343,8 +343,8 @@ impl MessageHandler> for Navigat let (pos1, pos2) = (pos1.min(pos2), pos1.max(pos2)); let diagonal = pos2 - pos1; - if diagonal.length() < f64::EPSILON * 1000. || viewport.size().into_dvec2() == DVec2::ZERO { - warn!("Cannot center since the viewport size is 0"); + if !diagonal.is_finite() || diagonal.length() < f64::EPSILON * 1000. || viewport.size().into_dvec2() == DVec2::ZERO { + warn!("Cannot center since the viewport size is 0 or the bounds are non-finite"); return; } diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index dc17eb5aec..a7f742a2d3 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -462,184 +462,131 @@ fn document_node_definitions() -> HashMap 0[0:Floor] - // [0:Floor]0 -> 0[1:Subtract] - // "1: f64" -> 1[1:Subtract] - // "(): ()" -> 0[2:Read Index] - // "0: u32" -> 1[2:Read Index] - // [2:Read Index]0 -> 0[3:Divide] - // [1:Subtract]0 -> 1[3:Divide] - // [IMPORTS]1 -> 0[4:Position on Path] - // [3:Divide]0 -> 1[4:Position on Path] - // "false: bool" -> 2[4:Position on Path] - // "false: bool" -> 3[4:Position on Path] - // "(): ()" -> 0[5:Read Vector] - // [5:Read Vector]0 -> 0[6:Reset Transform] - // "true: bool" -> 1[6:Reset Transform] - // "false: bool" -> 2[6:Reset Transform] - // "false: bool" -> 3[6:Reset Transform] - // [12:Flatten Vector]0 -> 0[7:Map] - // [6:Reset Transform]0 -> 1[7:Map] - // [7:Map]0 -> 0[8:Morph] - // [15:Multiply]0 -> 1[8:Morph] - // [8:Morph]0 -> 0[9:Transform] - // [4:Position on Path]0 -> 1[9:Transform] - // "0: f64" -> 2[9:Transform] - // "(0, 0): DVec2" -> 3[9:Transform] - // "(0, 0): DVec2" -> 4[9:Transform] - // [IMPORTS]1 -> 0[10:Count Points] - // [10:Count Points]0 -> 0[11:Equals] - // [13:Count Elements]0 -> 1[11:Equals] - // [IMPORTS]0 -> 0[12:Flatten Vector] - // [12:Flatten Vector]0 -> 0[13:Count Elements] - // [13:Count Elements]0 -> 0[14:Subtract] - // "1: f64" -> 1[14:Subtract] - // [3:Divide]0 -> 0[15:Multiply] - // [14:Subtract]0 -> 1[15:Multiply] - // [12:Flatten Vector]0 -> 0[16:Morph] - // [15:Multiply]0 -> 1[16:Morph] - // [11:Equals]0 -> 0[17:Switch] - // [9:Transform]0 -> 1[17:Switch] - // [16:Morph]0 -> 2[17:Switch] - // [17:Switch]0 -> 0[18:Repeat] - // [0:Floor]0 -> 1[18:Repeat] - // [IMPORTS]3 -> 2[18:Repeat] - // [18:Repeat]0 -> 0[EXPORTS] node_template: NodeTemplate { document_node: DocumentNode { implementation: DocumentNodeImplementation::Network(NodeNetwork { - exports: vec![NodeInput::node(NodeId(18), 0)], + exports: vec![NodeInput::node(NodeId(16), 0)], nodes: [ - // 0: Floor + // 0: Separate Subpaths (split path into individual subpaths) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), - inputs: vec![NodeInput::import(concrete!(f64), 2)], + implementation: DocumentNodeImplementation::ProtoNode(vector::separate_subpaths::IDENTIFIER), + inputs: vec![NodeInput::import(generic!(T), 4)], ..Default::default() }, - // 1: Subtract + // 1: Count Elements (number of subpaths) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(1.), false)], + implementation: DocumentNodeImplementation::ProtoNode(vector::count_elements::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(0), 0)], ..Default::default() }, - // 2: Read Index + // 2: Max (clamp subpath count to at least 1 for empty path case) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(context::read_index::IDENTIFIER), - inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::U32(0), false)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::max::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(1), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, - // 3: Divide + // 3: Floor (integer count per subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(2), 0), NodeInput::node(NodeId(1), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), + inputs: vec![NodeInput::import(concrete!(f64), 1)], ..Default::default() }, - // 4: Position on Path + // 4: Multiply (total_instances = count × subpath_count) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector_nodes::position_on_path::IDENTIFIER), - inputs: vec![ - NodeInput::import(generic!(T), 1), - NodeInput::node(NodeId(3), 0), - NodeInput::value(TaggedValue::Bool(false), false), - NodeInput::value(TaggedValue::Bool(false), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(2), 0)], ..Default::default() }, - // 5: Read Vector + // 5: Subtract (count - 1, open subpath denominator) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(context::read_vector::IDENTIFIER), - inputs: vec![NodeInput::value(TaggedValue::None, false)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, - // 6: Reset Transform + // 6: Read Index (current repetition index) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::reset_transform::IDENTIFIER), - inputs: vec![ - NodeInput::node(NodeId(5), 0), - NodeInput::value(TaggedValue::Bool(true), false), - NodeInput::value(TaggedValue::Bool(false), false), - NodeInput::value(TaggedValue::Bool(false), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(context::read_index::IDENTIFIER), + inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::U32(0), false)], ..Default::default() }, - // 7: Map + // 7: Divide (index / count) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(graphic::map::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::node(NodeId(6), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(6), 0), NodeInput::node(NodeId(17), 0)], ..Default::default() }, - // 8: Morph + // 8: Floor (floor(index / count) = subpath index) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector::morph::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(7), 0), NodeInput::node(NodeId(15), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::floor::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(7), 0)], ..Default::default() }, - // 9: Transform + // 9: Modulo (index % count = local index within subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(transform_nodes::transform::IDENTIFIER), - inputs: vec![ - NodeInput::node(NodeId(8), 0), - NodeInput::node(NodeId(4), 0), - NodeInput::value(TaggedValue::F64(0.), false), - NodeInput::value(TaggedValue::DVec2(DVec2::ONE), false), - NodeInput::value(TaggedValue::DVec2(DVec2::ZERO), false), - ], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::modulo::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(6), 0), NodeInput::node(NodeId(17), 0), NodeInput::value(TaggedValue::Bool(true), false)], ..Default::default() }, - // 10: Count Points + // 10: Path Is Closed (check if current subpath is closed) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector_nodes::count_points::IDENTIFIER), - inputs: vec![NodeInput::import(generic!(T), 1)], + implementation: DocumentNodeImplementation::ProtoNode(vector::path_is_closed::IDENTIFIER), + inputs: vec![NodeInput::import(generic!(T), 4), NodeInput::node(NodeId(8), 0)], ..Default::default() }, - // 11: Equals + // 11: Switch (closed → count, open → max(count - 1, 1) as denominator) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::equals::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(10), 0), NodeInput::node(NodeId(13), 0)], + implementation: DocumentNodeImplementation::ProtoNode(logic::switch::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(10), 0), NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(18), 0)], ..Default::default() }, - // 12: Flatten Vector + // 12: Divide (local_index / denominator = within-subpath fraction) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(graphic_nodes::graphic::flatten_vector::IDENTIFIER), - inputs: vec![NodeInput::import(generic!(T), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::divide::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(9), 0), NodeInput::node(NodeId(11), 0)], ..Default::default() }, - // 13: Count Elements + // 13: Multiply (fraction × 0.9999999999 to avoid overflowing to the next subpath) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector::count_elements::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::value(TaggedValue::F64(0.9999999999), false)], ..Default::default() }, - // 14: Subtract + // 14: Add (subpath_index + clamped fraction = progression) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::subtract::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(13), 0), NodeInput::value(TaggedValue::F64(1.), false)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::add::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(8), 0), NodeInput::node(NodeId(13), 0)], ..Default::default() }, - // 15: Multiply + // 15: Morph (content, progression, reverse, distribution, path) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(math_nodes::multiply::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(3), 0), NodeInput::node(NodeId(14), 0)], + implementation: DocumentNodeImplementation::ProtoNode(vector::morph::IDENTIFIER), + inputs: vec![ + NodeInput::import(generic!(T), 0), + NodeInput::node(NodeId(14), 0), + NodeInput::value(TaggedValue::Bool(false), false), + NodeInput::import(concrete!(vector::misc::InterpolationDistribution), 3), + NodeInput::import(generic!(T), 4), + ], ..Default::default() }, - // 16: Morph + // 16: Repeat DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(vector::morph::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(12), 0), NodeInput::node(NodeId(15), 0)], + implementation: DocumentNodeImplementation::ProtoNode(repeat_nodes::repeat::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(15), 0), NodeInput::node(NodeId(4), 0), NodeInput::import(generic!(T), 2)], ..Default::default() }, - // 17: Switch + // 17: Max (clamp count to at least 1) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(logic::switch::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(11), 0), NodeInput::node(NodeId(9), 0), NodeInput::node(NodeId(16), 0)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::max::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(3), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, - // 18: Repeat + // 18: Max (clamp open-path denominator to at least 1 to avoid division by zero when count = 1) DocumentNode { - implementation: DocumentNodeImplementation::ProtoNode(repeat_nodes::repeat::IDENTIFIER), - inputs: vec![NodeInput::node(NodeId(17), 0), NodeInput::node(NodeId(0), 0), NodeInput::import(generic!(T), 3)], + implementation: DocumentNodeImplementation::ProtoNode(math_nodes::max::IDENTIFIER), + inputs: vec![NodeInput::node(NodeId(5), 0), NodeInput::value(TaggedValue::F64(1.), false)], ..Default::default() }, ] @@ -650,168 +597,175 @@ fn document_node_definitions() -> HashMap HashMap 0[0:Read Vector] - // [0:Read Vector]0 -> 0[1:Extract Transform] - // [1:Extract Transform]0 -> 0[2:Decompose Translation] - // [2:Decompose Translation]0 -> 0[3:Vec2 to Point] - // [IMPORTS]0 -> 0[4:Flatten Vector] - // [4:Flatten Vector]0 -> 0[5:Map] - // [3:Vec2 to Point]0 -> 1[5:Map] - // [5:Map]0 -> 0[6: Flatten Path] - // [6:Flatten Path]0 -> 0[7:Points to Polyline] - // "false: bool" -> 1[7:Points to Polyline] - // [7:Points to Polyline]0 -> 0[EXPORTS] node_template: NodeTemplate { document_node: DocumentNode { implementation: DocumentNodeImplementation::Network(NodeNetwork { diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8f428a7286..a84e5cb127 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -26,7 +26,7 @@ use graphene_std::text::{Font, TextAlign}; use graphene_std::transform::{Footprint, ReferencePoint, ScaleType, Transform}; use graphene_std::vector::QRCodeErrorCorrectionLevel; use graphene_std::vector::misc::BooleanOperation; -use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; +use graphene_std::vector::misc::{ArcType, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType}; use graphene_std::vector::style::{Fill, FillChoice, FillType, GradientStops, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin}; pub(crate) fn string_properties(text: &str) -> Vec { @@ -266,6 +266,7 @@ pub(crate) fn property_from_type( Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), + Some(x) if x == TypeId::of::() => enum_choice::().for_socket(default_info).property_row(), // ===== // OTHER // ===== diff --git a/editor/src/messages/portfolio/document/utility_types/misc.rs b/editor/src/messages/portfolio/document/utility_types/misc.rs index 7ea2b5dc21..30e6a1e013 100644 --- a/editor/src/messages/portfolio/document/utility_types/misc.rs +++ b/editor/src/messages/portfolio/document/utility_types/misc.rs @@ -1,5 +1,6 @@ use crate::consts::COLOR_OVERLAY_GRAY; use glam::DVec2; +use graphene_std::vector::misc::BooleanOperation; use std::fmt; #[repr(transparent)] @@ -710,5 +711,7 @@ impl PTZ { #[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub enum GroupFolderType { Layer, - BooleanOperation(graphene_std::vector::misc::BooleanOperation), + BooleanOperation(BooleanOperation), + Blend, + Morph, } diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index a1fd08e4f0..6a89dc355a 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -1209,6 +1209,8 @@ impl NodeNetworkInterface { } self.document_metadata.bounding_box_document(layer) }) + // Skip any layer bounds containing NaN to avoid poisoning the combined result + .filter(|[min, max]| min.is_finite() && max.is_finite()) .reduce(Quad::combine_bounds) } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index c82892adee..3f89b6de4a 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -474,13 +474,14 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::blending_nodes::blend::IDENTIFIER, + node: graphene_std::raster_nodes::blending_nodes::mix::IDENTIFIER, aliases: &[ "graphene_raster_nodes::adjustments::BlendNode", "raster_nodes::adjustments::BlendNode", "graphene_core::raster::adjustments::BlendNode", "graphene_core::raster::BlendNode", "graphene_raster_nodes::blending_nodes::BlendNode", + "raster_nodes::blending_nodes::BlendNode", ], }, NodeReplacement { @@ -1664,7 +1665,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 1), NodeInput::value(TaggedValue::U32(0), false), network_path); } - // Migrate from the old source/target "Morph" node to the new vector table based "Morph" node. + // Migrate from the old source/target v1 "Morph" node to the new vector table based v2 "Morph" node. // This doesn't produce exactly equivalent results in cases involving input vector tables with multiple rows. // The old version would zip the source and target table rows, interpoleating each pair together. // The migrated version will instead deeply flatten both merged tables and morph sequentially between all source vectors and all target vector elements. @@ -1676,7 +1677,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], // 4 inputs - even older signature (commit 80b8df8d4298b6669f124b929ce61bfabfc44e41): // async fn morph(_: impl Ctx, source: Table, #[expose] target: Table, #[default(0.5)] time: Fraction, #[min(0.)] start_index: IntegerCount) -> Table { ... } // - // New signature: + // v2 signature: // async fn morph(_: impl Ctx, #[implementations(Table, Table)] content: I, progression: Progression) -> Table { ... } let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); @@ -1712,6 +1713,84 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], .set_input(&InputConnector::node(*node_id, 0), NodeInput::node(merge_node_id, 0), network_path); // Connect the old 'progression' input to the new 'progression' input of the Morph node document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[2].clone(), network_path); + + inputs_count = 2; + } + + // Migrate from the v2 "Morph" node (2 inputs: content, progression) to the v3 "Morph" node (5 inputs: content, progression, reverse, distribution, path). + // The old progression used integer part for pair selection (range 0..N-1 where N is the number of content objects). + // The new progression uses fractional 0..1 for euclidean traversal through all objects. + // We insert Count Elements → Subtract 1 → Divide to remap: new_progression = old_progression / (N - 1). + // For the common 2-object case (N=2), this divides by 1 which is a no-op, preserving identical behavior. + if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::morph::IDENTIFIER) && inputs_count == 2 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + + // Reconnect content (input 0) and leave path (input 4) as default + document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); + + let Some(morph_position) = document.network_interface.position_from_downstream_node(node_id, network_path) else { + log::error!("Could not get position for morph node {node_id}"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + + // Create Count Elements node: counts content table rows → N + let Some(count_elements_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::count_elements::IDENTIFIER)) else { + log::error!("Could not get count_elements node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let count_elements_template = count_elements_def.default_node_template(); + let count_elements_id = NodeId::new(); + + // Create Subtract node: N - 1 → N-1 + let Some(subtract_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::subtract::IDENTIFIER)) else { + log::error!("Could not get subtract node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let mut subtract_template = subtract_def.default_node_template(); + subtract_template.document_node.inputs[1] = NodeInput::value(TaggedValue::F64(1.), false); + let subtract_id = NodeId::new(); + + // Create Divide node: old_progression / (N-1) → new progression + let Some(divide_def) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::divide::IDENTIFIER)) else { + log::error!("Could not get divide node from definition when upgrading morph"); + document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); + return None; + }; + let divide_template = divide_def.default_node_template(); + let divide_id = NodeId::new(); + + // Insert and position nodes + document.network_interface.insert_node(count_elements_id, count_elements_template, network_path); + document + .network_interface + .shift_absolute_node_position(&count_elements_id, morph_position + IVec2::new(-21, 2), network_path); + + document.network_interface.insert_node(subtract_id, subtract_template, network_path); + document.network_interface.shift_absolute_node_position(&subtract_id, morph_position + IVec2::new(-14, 2), network_path); + + document.network_interface.insert_node(divide_id, divide_template, network_path); + document.network_interface.shift_absolute_node_position(÷_id, morph_position + IVec2::new(-7, 1), network_path); + + // Wire: content source → Count Elements input 0 + document.network_interface.set_input(&InputConnector::node(count_elements_id, 0), old_inputs[0].clone(), network_path); + + // Wire: Count Elements output → Subtract input 0 (minuend) + document + .network_interface + .set_input(&InputConnector::node(subtract_id, 0), NodeInput::node(count_elements_id, 0), network_path); + + // Wire: old progression → Divide input 0 (numerator) + document.network_interface.set_input(&InputConnector::node(divide_id, 0), old_inputs[1].clone(), network_path); + + // Wire: Subtract output → Divide input 1 (denominator) + document.network_interface.set_input(&InputConnector::node(divide_id, 1), NodeInput::node(subtract_id, 0), network_path); + + // Wire: Divide output → Morph progression input + document.network_interface.set_input(&InputConnector::node(*node_id, 1), NodeInput::node(divide_id, 0), network_path); } // Migrate old Arrow node from (start, end, shaft_width, head_width, head_length) to (arrow_to, shaft_width, head_width, head_length) with a Transform node for positioning diff --git a/editor/src/messages/tool/common_functionality/shape_editor.rs b/editor/src/messages/tool/common_functionality/shape_editor.rs index 0183d65cc3..0655bb93e1 100644 --- a/editor/src/messages/tool/common_functionality/shape_editor.rs +++ b/editor/src/messages/tool/common_functionality/shape_editor.rs @@ -1822,7 +1822,7 @@ impl ShapeState { /// Find the `t` value along the path segment we have clicked upon, together with that segment ID. fn closest_segment(&self, network_interface: &NodeNetworkInterface, layer: LayerNodeIdentifier, position: glam::DVec2, tolerance: f64) -> Option { let transform = network_interface.document_metadata().transform_to_viewport_if_feeds(layer, network_interface); - let layer_pos = transform.inverse().transform_point2(position); + let layer_pos = (transform.matrix2.determinant().abs() >= f64::EPSILON).then(|| transform.inverse().transform_point2(position))?; let tolerance = tolerance + 0.5; diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 9b23da303a..dca36fdb82 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -154,6 +154,8 @@ import HistoryRedo from "/../branding/assets/icon-16px-solid/history-redo.svg"; import HistoryUndo from "/../branding/assets/icon-16px-solid/history-undo.svg"; import IconsGrid from "/../branding/assets/icon-16px-solid/icons-grid.svg"; import Image from "/../branding/assets/icon-16px-solid/image.svg"; +import InterpolationBlend from "/../branding/assets/icon-16px-solid/interpolation-blend.svg"; +import InterpolationMorph from "/../branding/assets/icon-16px-solid/interpolation-morph.svg"; import Layer from "/../branding/assets/icon-16px-solid/layer.svg"; import License from "/../branding/assets/icon-16px-solid/license.svg"; import NewLayer from "/../branding/assets/icon-16px-solid/new-layer.svg"; @@ -271,6 +273,8 @@ const SOLID_16PX = { HistoryUndo: { svg: HistoryUndo, size: 16 }, IconsGrid: { svg: IconsGrid, size: 16 }, Image: { svg: Image, size: 16 }, + InterpolationBlend: { svg: InterpolationBlend, size: 16 }, + InterpolationMorph: { svg: InterpolationMorph, size: 16 }, Layer: { svg: Layer, size: 16 }, License: { svg: License, size: 16 }, NewLayer: { svg: NewLayer, size: 16 }, diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 5ea1d42e72..d38b556354 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -260,6 +260,7 @@ tagged_value! { ExtrudeJoiningAlgorithm(vector::misc::ExtrudeJoiningAlgorithm), PointSpacingType(vector::misc::PointSpacingType), SpiralType(vector::misc::SpiralType), + InterpolationDistribution(vector::misc::InterpolationDistribution), #[serde(alias = "LineCap")] StrokeCap(vector::style::StrokeCap), #[serde(alias = "LineJoin")] diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index 4f0d6716a7..f8cc25f7c7 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -135,6 +135,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::CentroidType]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), + async_node!(graphene_core::memo::MonitorNode<_, _, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::InterpolationDistribution]), // Context nullification #[cfg(feature = "gpu")] async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => &WasmEditorApi, Context => graphene_std::ContextFeatures]), @@ -229,6 +230,7 @@ fn node_registry() -> HashMap, input: Context, fn_params: [Context => graphene_std::vector::misc::BooleanOperation]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::text::TextAlign]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::transform::ScaleType]), + async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => graphene_std::vector::misc::InterpolationDistribution]), async_node!(graphene_core::memo::MemoNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate]), ]; // ============= diff --git a/node-graph/libraries/vector-types/src/vector/misc.rs b/node-graph/libraries/vector-types/src/vector/misc.rs index 3270021931..7f5711da75 100644 --- a/node-graph/libraries/vector-types/src/vector/misc.rs +++ b/node-graph/libraries/vector-types/src/vector/misc.rs @@ -554,3 +554,21 @@ pub enum SpiralType { Archimedean, Logarithmic, } + +/// Controls how the morph/blend progression spends its time along the interpolation path, allowing for constant speed/spacing with respect to different parameters of change. +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Hash, DynAny, node_macro::ChoiceType)] +#[widget(Dropdown)] +pub enum InterpolationDistribution { + /// All objects occupy an equal portion of the progression range, regardless of their changing distances, angles, sizes, or slants. + #[default] + Objects, + /// All distances along the interpolation path are covered at a constant rate, meaning more time is spent traversing further distances. + Distances, + /// All angles of rotation between objects are covered at a constant rate, meaning more time is spent turning through larger angles. + Angles, + /// All sizes of expansion/contraction between objects are covered at a constant rate, meaning more time is spent scaling through larger (logarithmic) changes in size. + Sizes, + /// All slants (changes in skew angle) between objects are covered at a constant rate, meaning more time is spent skewing through larger changes in slant. + Slants, +} diff --git a/node-graph/nodes/raster/src/blending_nodes.rs b/node-graph/nodes/raster/src/blending_nodes.rs index 510b12eead..80315a5fe3 100644 --- a/node-graph/nodes/raster/src/blending_nodes.rs +++ b/node-graph/nodes/raster/src/blending_nodes.rs @@ -130,7 +130,7 @@ pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendM } #[node_macro::node(category("Raster"), cfg(feature = "std"))] -fn blend + Send>( +fn mix + Send>( _: impl Ctx, #[implementations( Table>, diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index f33d1a17fc..6586b8eac0 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -4,14 +4,14 @@ use core::hash::{Hash, Hasher}; use core_types::bounds::{BoundingBox, RenderBoundingBox}; use core_types::registry::types::{Angle, Length, Multiplier, Percentage, PixelLength, Progression, SeedValue}; use core_types::table::{Table, TableRow, TableRowMut}; -use core_types::transform::Footprint; +use core_types::transform::{Footprint, Transform}; use core_types::{CloneVarArgs, Color, Context, Ctx, ExtractAll, OwnedContextImpl}; use glam::{DAffine2, DVec2}; use graphic_types::Vector; use graphic_types::raster_types::{CPU, GPU, Raster}; use graphic_types::{Graphic, IntoGraphicTable}; use kurbo::simplify::{SimplifyOptions, simplify_bezpath}; -use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, PathEl, PathSeg, Shape}; +use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape}; use rand::{Rng, SeedableRng}; use std::collections::hash_map::DefaultHasher; use vector_types::subpath::{BezierHandles, ManipulatorGroup}; @@ -21,8 +21,8 @@ use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt; use vector_types::vector::algorithms::offset_subpath::offset_bezpath; use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open}; use vector_types::vector::misc::{ - CentroidType, ExtrudeJoiningAlgorithm, HandleId, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, bezpath_to_manipulator_groups, handles_to_segment, - is_linear, point_to_dvec2, segment_to_handles, + CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups, + bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles, }; use vector_types::vector::style::{Fill, Gradient, GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt}; @@ -1263,7 +1263,7 @@ async fn separate_subpaths(_: impl Ctx, content: Table) -> Table } /// Determines if the subpath at the given index (across all vector element subpaths) is closed, meaning its ends are connected together forming a loop. -#[node_macro::node(name("Path is Closed"), category("Vector"), path(core_types::vector))] +#[node_macro::node(name("Path is Closed"), category("Vector: Measure"), path(core_types::vector))] async fn path_is_closed( _: impl Ctx, /// The vector content whose subpaths are inspected. @@ -1938,199 +1938,473 @@ async fn jitter_points( .collect() } -/// Interpolates the geometry and styles between multiple vector layers, producing a single morphed vector shape. +/// Interpolates the geometry, appearance, and transform between multiple vector layers, producing a single morphed vector shape. /// -/// Based on the progression value, adjacent vector elements are blended together. From 0 until 1, the first element (bottom layer) morphs into the second element (next layer up). From 1 until 2, it then morphs into the third element, and so on until progression is capped at the last element (top layer). +/// Progression [0, 1) morphs through all objects at uniform speed. A path may be provided to control the trajectory between key objects. The **Origins to Polyline** node may be used to create a path with anchor points corresponding to each object. Other nodes can modify its path segments. #[node_macro::node(category("Vector: Modifier"), path(core_types::vector))] async fn morph( _: impl Ctx, - /// The vector elements to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements. + /// The vector objects to interpolate between. Mixed graphic content is deeply flattened to keep only vector elements. #[implementations(Table, Table)] content: I, - /// The factor from one vector element to the next in sequence. The whole number part selects the source element, and the decimal part determines the interpolation amount towards the next element. + /// The fractional part [0, 1) traverses the morph uniformly along the path. If the control path has multiple subpaths, each added integer selects the next subpath. progression: Progression, + /// Swap the direction of the progression between objects or along the control path. + reverse: bool, + /// The parameter of change that influences the interpolation speed between each object. Equal slices in this parameter correspond to the rate of progression through the morph. This must be set to a parameter that changes. + /// + /// "Objects" morphs through each group element at an equal rate. "Distances" keeps constant speed with time between objects proportional to their distances. "Angles" keeps constant rotational speed. "Sizes" keeps constant shrink/growth speed. "Slants" keeps constant shearing angle speed. + distribution: InterpolationDistribution, + /// An optional control path whose anchor points correspond to each object. Curved segments between points will shape the morph trajectory instead of traveling straight. If there is a break between path segments, the separate subpaths are selected by index from the integer part of the progression value. For example, [1, 2) morphs along the segments of the second subpath, and so on. + path: Table, ) -> Table { - /// Subdivides the last segment of the bezpath to until it appends 'count' number of segments. - fn make_new_segments(bezpath: &mut BezPath, count: usize) { - let bezpath_segment_count = bezpath.segments().count(); + /// Promotes a segment's handle pair to cubic-equivalent Bézier control points. + /// For linear segments (both None), handles are placed at their respective anchors (zero-length) + /// so that interpolation against another zero-length cubic doesn't introduce unwanted curvature. + /// For quadratic segments (one handle), degree elevation is applied. + fn promote_handles_to_cubic(prev_anchor: DVec2, out_handle: Option, in_handle: Option, curr_anchor: DVec2) -> (DVec2, DVec2) { + match (out_handle, in_handle) { + (Some(handle_start), Some(handle_end)) => (handle_start, handle_end), + (None, None) => (prev_anchor, curr_anchor), + (Some(handle), None) | (None, Some(handle)) => { + let handle_start = prev_anchor + (handle - prev_anchor) * (2. / 3.); + let handle_end = curr_anchor + (handle - curr_anchor) * (2. / 3.); + (handle_start, handle_end) + } + } + } - if count == 0 || bezpath_segment_count == 0 { + /// Subdivides the last segment of a manipulator group list at its midpoint, adding one new manipulator. + /// For closed paths, the "last segment" is the closing segment from the last back to the first manipulator. + fn subdivide_last_manipulator_segment(manips: &mut Vec>, closed: bool) { + let len = manips.len(); + if len < 2 { return; } - // Initially push the last segment of the bezpath - let mut new_segments = vec![bezpath.get_seg(bezpath_segment_count).unwrap()]; + let (prev_idx, next_idx) = if closed { (len - 1, 0) } else { (len - 2, len - 1) }; + + let prev_anchor = manips[prev_idx].anchor; + let next_anchor = manips[next_idx].anchor; + let (h1, h2) = promote_handles_to_cubic(prev_anchor, manips[prev_idx].out_handle, manips[next_idx].in_handle, next_anchor); + + // De Casteljau subdivision at t=0.5 + let m01 = prev_anchor.lerp(h1, 0.5); + let m12 = h1.lerp(h2, 0.5); + let m23 = h2.lerp(next_anchor, 0.5); + let m012 = m01.lerp(m12, 0.5); + let m123 = m12.lerp(m23, 0.5); + let mid = m012.lerp(m123, 0.5); + + manips[prev_idx].out_handle = Some(m01); + manips[next_idx].in_handle = Some(m23); - // Generate new segments by subdividing last segment - for _ in 0..count { - let last = new_segments.pop().unwrap(); - let (first, second) = last.subdivide(); - new_segments.push(first); - new_segments.push(second); + let mid_manip = ManipulatorGroup { + anchor: mid, + in_handle: Some(m012), + out_handle: Some(m123), + id: PointId::ZERO, + }; + + if closed { + manips.push(mid_manip); + } else { + manips.insert(next_idx, mid_manip); } + } - // Append the new segments. - if count != 0 { - // Remove the last segment as it is already appended to the new_segments. - let mut is_closed = false; - if let Some(last_element) = bezpath.pop() - && last_element == PathEl::ClosePath - { - is_closed = true; - _ = bezpath.pop(); - } + /// Constructs BezierHandles from the out_handle of one manipulator and in_handle of the next. + fn handles_from_manips(out_handle: Option, in_handle: Option) -> BezierHandles { + match (out_handle, in_handle) { + (Some(handle_start), Some(handle_end)) => BezierHandles::Cubic { handle_start, handle_end }, + (None, None) => BezierHandles::Linear, + (Some(handle), None) | (None, Some(handle)) => BezierHandles::Quadratic { handle }, + } + } - for segment in new_segments { - if bezpath.elements().is_empty() { - bezpath.move_to(segment.start()) - } - bezpath.push(segment.as_path_el()); - } + /// Pushes a subpath (list of manipulators) directly into a Vector's point, segment, and region domains, + /// bypassing the BezPath intermediate representation used by `append_bezpath`. + fn push_manipulators_to_vector(vector: &mut Vector, manips: &[ManipulatorGroup], closed: bool, point_id: &mut PointId, segment_id: &mut SegmentId) { + let Some(first) = manips.first() else { return }; - if is_closed { - bezpath.close_path(); - } + let first_point_index = vector.point_domain.ids().len(); + vector.point_domain.push(point_id.next_id(), first.anchor); + let mut prev_point_index = first_point_index; + let mut first_segment_id = None; + + for manip_window in manips.windows(2) { + let point_index = vector.point_domain.ids().len(); + vector.point_domain.push(point_id.next_id(), manip_window[1].anchor); + + let handles = handles_from_manips(manip_window[0].out_handle, manip_window[1].in_handle); + let seg_id = segment_id.next_id(); + first_segment_id.get_or_insert(seg_id); + vector.segment_domain.push(seg_id, prev_point_index, point_index, handles, StrokeId::ZERO); + + prev_point_index = point_index; + } + + if closed && manips.len() > 1 { + let handles = handles_from_manips(manips.last().unwrap().out_handle, manips[0].in_handle); + let closing_seg_id = segment_id.next_id(); + first_segment_id.get_or_insert(closing_seg_id); + vector.segment_domain.push(closing_seg_id, prev_point_index, first_point_index, handles, StrokeId::ZERO); + + let region_id = vector.region_domain.next_id(); + vector.region_domain.push(region_id, first_segment_id.unwrap()..=closing_seg_id, FillId::ZERO); } } // Preserve original graphic table as upstream data so this group layer's nested layers can be edited by the tools. - let graphic_table_content = content.clone().into_graphic_table(); + let mut graphic_table_content = content.clone().into_graphic_table(); // If the input isn't a Table, we convert it into one by flattening any Table content. let content = content.into_flattened_table::(); - // Determine source and target indices and interpolation time fraction - let progression = progression.max(0.); - let source_index = progression.floor() as usize; - let time = progression.fract(); - // Not enough elements to interpolate between, so we return the input as-is if content.len() <= 1 { return content; } - // Progression is at or past the last element, so we return the last element without interpolation - if source_index >= content.len() - 1 { - return content.into_iter().last().into_iter().collect(); - } - // Interpolation between two elements - let mut content_iter = content.into_iter(); - let source_row = content_iter.nth(source_index).unwrap(); - let target_row = content_iter.next().unwrap(); + // Build the control path for the morph trajectory. + // Collect all subpaths from the path input (applying transforms), or build a default polyline from element origins. + let default_polyline = || { + let mut default_path = BezPath::new(); + for (i, row) in content.iter().enumerate() { + let origin = row.transform.translation; + let point = kurbo::Point::new(origin.x, origin.y); + if i == 0 { + default_path.move_to(point); + } else { + default_path.line_to(point); + } + } + vec![default_path] + }; + + let control_bezpaths: Vec = if path.is_empty() { + default_polyline() + } else { + // User-provided path: collect all subpaths with transforms applied + let paths: Vec = path + .iter() + .flat_map(|vector| { + let transform = *vector.transform; + vector.element.stroke_bezpath_iter().map(move |mut bezpath| { + bezpath.apply_affine(Affine::new(transform.to_cols_array())); + bezpath + }) + }) + .collect(); - let mut vector = Vector { - upstream_data: Some(graphic_table_content), - ..Default::default() + // Fall back to default polyline if the user-provided path has no subpaths + if paths.is_empty() { default_polyline() } else { paths } }; - // Lerp styles - let vector_alpha_blending = source_row.alpha_blending.lerp(&target_row.alpha_blending, time as f32); - vector.style = source_row.element.style.lerp(&target_row.element.style, time); + // Select which subpath to use based on the integer part of progression (like the 'Position on Path' node) + let progression = progression.max(0.); + let subpath_count = control_bezpaths.len() as f64; + let progression = if reverse { subpath_count - progression } else { progression }; + let clamped_progression = progression.clamp(0., subpath_count); + let subpath_index = if clamped_progression >= subpath_count { subpath_count - 1. } else { clamped_progression } as usize; + let fractional_progression = if clamped_progression >= subpath_count { 1. } else { clamped_progression.fract() }; - // Before and after transforms - let source_transform = source_row.transform; - let target_transform = target_row.transform; + let control_bezpath = &control_bezpaths[subpath_index]; + let segment_count = control_bezpath.segments().count(); - // Before and after paths - let source_bezpaths = source_row.element.stroke_bezpath_iter(); - let target_bezpaths = target_row.element.stroke_bezpath_iter(); + // If the control path has no segments, return the first element + if segment_count == 0 { + return content.into_iter().next().into_iter().collect(); + } - for (mut source_bezpath, mut target_bezpath) in source_bezpaths.zip(target_bezpaths) { - if source_bezpath.elements().is_empty() || target_bezpath.elements().is_empty() { - continue; - } + // Determine if the selected subpath is closed (has a closing segment connecting its end back to its start) + let is_closed = control_bezpath.elements().last() == Some(&PathEl::ClosePath); + + // Number of anchor points (content elements) per subpath: for closed subpaths, the closing + // segment doesn't add a new anchor, so anchors = segments. For open: anchors = segments + 1. + let anchor_count = |bp: &BezPath| -> usize { + let segs = bp.segments().count(); + let closed = bp.elements().last() == Some(&PathEl::ClosePath); + if closed { segs } else { segs + 1 } + }; + + // Offset source_index by the number of content elements consumed by previous subpaths, + // so each subpath morphs through its own slice of content (not always starting from element 0). + let content_offset: usize = control_bezpaths[..subpath_index].iter().map(&anchor_count).sum(); + let subpath_anchors = anchor_count(control_bezpath); + let max_content_index = content.len().saturating_sub(1); + + // Compute per-segment arc lengths for spatial positioning along the control path + let segment_lengths: Vec = control_bezpath.segments().map(|seg| seg.perimeter(DEFAULT_ACCURACY)).collect(); + + // Compute segment weights based on the user's chosen spacing metric + let segment_weights: Vec = match distribution { + InterpolationDistribution::Objects => vec![1.; segment_count], + InterpolationDistribution::Distances => segment_lengths.clone(), + InterpolationDistribution::Angles | InterpolationDistribution::Sizes | InterpolationDistribution::Slants => (0..segment_count) + .map(|i| { + let src_idx = (content_offset + i).min(max_content_index); + let tgt_idx = if is_closed && i >= subpath_anchors - 1 { + content_offset + } else { + (content_offset + i + 1).min(max_content_index) + }; - source_bezpath.apply_affine(Affine::new(source_transform.to_cols_array())); - target_bezpath.apply_affine(Affine::new(target_transform.to_cols_array())); - - let target_segment_len = target_bezpath.segments().count(); - let source_segment_len = source_bezpath.segments().count(); - - // Insert new segments to align the number of segments in source_bezpath and target_bezpath. - make_new_segments(&mut source_bezpath, target_segment_len.max(source_segment_len) - source_segment_len); - make_new_segments(&mut target_bezpath, source_segment_len.max(target_segment_len) - target_segment_len); - - let source_segments = source_bezpath.segments().collect::>(); - let target_segments = target_bezpath.segments().collect::>(); - - // Interpolate anchors and handles - for (i, (source_element, target_element)) in source_bezpath.elements_mut().iter_mut().zip(target_bezpath.elements_mut().iter_mut()).enumerate() { - match source_element { - PathEl::MoveTo(point) => *point = point.lerp(target_element.end_point().unwrap(), time), - PathEl::ClosePath => {} - elm => { - let mut source_segment = source_segments.get(i - 1).unwrap().to_cubic(); - let target_segment = target_segments.get(i - 1).unwrap().to_cubic(); - source_segment.p0 = source_segment.p0.lerp(target_segment.p0, time); - source_segment.p1 = source_segment.p1.lerp(target_segment.p1, time); - source_segment.p2 = source_segment.p2.lerp(target_segment.p2, time); - source_segment.p3 = source_segment.p3.lerp(target_segment.p3, time); - *elm = PathSeg::Cubic(source_segment).as_path_el(); + let (Some(src), Some(tgt)) = (content.get(src_idx), content.get(tgt_idx)) else { return 0. }; + let (s_angle, s_scale, s_skew) = src.transform.decompose_rotation_scale_skew(); + let (t_angle, t_scale, t_skew) = tgt.transform.decompose_rotation_scale_skew(); + + match distribution { + InterpolationDistribution::Angles => { + let mut diff = t_angle - s_angle; + if diff > PI { + diff -= TAU; + } else if diff < -PI { + diff += TAU; + } + diff.abs() + } + InterpolationDistribution::Sizes => (t_scale - s_scale).length(), + InterpolationDistribution::Slants => (t_skew.atan() - s_skew.atan()).abs(), + _ => unreachable!(), } + }) + .collect(), + }; + + let total_weight: f64 = segment_weights.iter().sum(); + + // Map the fractional progression to a segment index and local blend time using the chosen weights. + // When all weights are zero (all elements identical in the chosen metric), there's zero interval to traverse. + let (local_source_index, time) = if total_weight <= f64::EPSILON { + (0, 0.) + } else if fractional_progression >= 1. { + (segment_count - 1, 1.) + } else { + let mut accumulator = 0.; + let mut found_index = segment_count - 1; + let mut found_t = 1.; + for (i, weight) in segment_weights.iter().enumerate() { + let ratio = weight / total_weight; + if fractional_progression <= accumulator + ratio { + found_index = i; + found_t = if ratio > f64::EPSILON { (fractional_progression - accumulator) / ratio } else { 0. }; + break; } + accumulator += ratio; } + (found_index, found_t) + }; + + // Convert the blend time to a parametric t for evaluating spatial position on the control path + let path_segment_index = local_source_index; + let parametric_t = { + let seg_idx = path_segment_index.min(segment_count - 1); + let segment = control_bezpath.get_seg(seg_idx + 1).unwrap(); + eval_pathseg_euclidean(segment, time, DEFAULT_ACCURACY) + }; + + let source_index = local_source_index + content_offset; - vector.append_bezpath(source_bezpath.clone()); + // For closed subpaths, the closing segment wraps target back to the first element of this subpath's slice. + // For open subpaths, target is simply the next element. + let target_index = if is_closed && local_source_index >= subpath_anchors - 1 { + content_offset // Wrap to first element of this subpath's slice + } else { + source_index + 1 + }; + + // Clamp to valid content range + let source_index = source_index.min(max_content_index); + let target_index = target_index.min(max_content_index); + + // At the end of an open subpath with no more interpolation needed, return the final element + if !is_closed && time >= 1. && local_source_index >= subpath_anchors - 2 { + return content.into_iter().nth(target_index).into_iter().collect(); } - // Deal with unmatched extra paths by collapsing them - let source_paths_count = source_row.element.stroke_bezpath_iter().count(); - let target_paths_count = target_row.element.stroke_bezpath_iter().count(); - let source_paths = source_row.element.stroke_bezpath_iter().skip(target_paths_count); - let target_paths = target_row.element.stroke_bezpath_iter().skip(source_paths_count); + // Use indexed access to borrow only the two rows we need, avoiding collecting the entire table + let (Some(source_row), Some(target_row)) = (content.get(source_index), content.get(target_index)) else { + return content; + }; + + // Lerp styles + let vector_alpha_blending = source_row.alpha_blending.lerp(target_row.alpha_blending, time as f32); + + // Evaluate the spatial position on the control path for the translation component. + // When the segment has zero arc length (e.g., two objects at the same position), inv_arclen + // produces NaN (0/0), so we fall back to the segment start point to avoid NaN translation. + let path_position = { + let segment_index = path_segment_index.min(segment_count - 1); + let segment = control_bezpath.get_seg(segment_index + 1).unwrap(); + let parametric_t = if segment.arclen(DEFAULT_ACCURACY) < f64::EPSILON { 0. } else { parametric_t }; + let point = segment.eval(parametric_t); + DVec2::new(point.x, point.y) + }; - for mut source_path in source_paths { - source_path.apply_affine(Affine::new(source_transform.to_cols_array())); + // Interpolate rotation, scale, and skew between source and target, but use the path position for translation. + // This decomposition must match the one used in Stroke::lerp so the renderer's stroke_transform.inverse() + // correctly cancels the element transform, keeping the stroke uniform when Stroke is after Transform. + let lerped_transform = { + let (s_angle, s_scale, s_skew) = source_row.transform.decompose_rotation_scale_skew(); + let (t_angle, t_scale, t_skew) = target_row.transform.decompose_rotation_scale_skew(); + + let lerp = |a: f64, b: f64| a + (b - a) * time; + + // Shortest-arc rotation interpolation + let mut rotation_diff = t_angle - s_angle; + if rotation_diff > PI { + rotation_diff -= TAU; + } else if rotation_diff < -PI { + rotation_diff += TAU; + } + let lerped_angle = s_angle + rotation_diff * time; - // Skip if the path has no segments else get the point at the end of the path. - let Some(end) = source_path.segments().last().map(|element| element.end()) else { continue }; + let trs = DAffine2::from_scale_angle_translation(s_scale.lerp(t_scale, time), lerped_angle, path_position); + let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]); + trs * skew + }; - for element in source_path.elements_mut() { - match element { - PathEl::MoveTo(point) => *point = point.lerp(end, time), - PathEl::LineTo(point) => *point = point.lerp(end, time), - PathEl::QuadTo(point, point1) => { - *point = point.lerp(end, time); - *point1 = point1.lerp(end, time); - } - PathEl::CurveTo(point, point1, point2) => { - *point = point.lerp(end, time); - *point1 = point1.lerp(end, time); - *point2 = point2.lerp(end, time); - } - PathEl::ClosePath => {} - } + // Pre-compensate upstream_data transforms so that when collect_metadata applies + // the row transform (which will be group_transform * lerped_transform after the + // pipeline's Transform node runs), the lerped_transform cancels out and children + // get the correct footprint: parent * group_transform * child_transform. + // Only pre-compensate if the lerped transform is invertible (non-zero determinant). + // A zero determinant can occur when interpolated scale passes through zero (e.g., flipped axes), + // in which case we skip pre-compensation to avoid propagating NaN through upstream_data transforms. + if lerped_transform.matrix2.determinant().abs() > f64::EPSILON { + let lerped_inverse = lerped_transform.inverse(); + for row in graphic_table_content.iter_mut() { + *row.transform = lerped_inverse * *row.transform; } - vector.append_bezpath(source_path); } - for mut target_path in target_paths { - target_path.apply_affine(Affine::new(source_transform.to_cols_array())); + let mut vector = Vector { + upstream_data: Some(graphic_table_content), + ..Default::default() + }; + vector.style = source_row.element.style.lerp(&target_row.element.style, time); + + // Work directly with manipulator groups, bypassing the BezPath intermediate representation. + // This avoids the full Vector → BezPath → interpolate → BezPath → Vector roundtrip each frame. + let mut source_subpaths: Vec<_> = source_row.element.stroke_manipulator_groups().collect(); + let mut target_subpaths: Vec<_> = target_row.element.stroke_manipulator_groups().collect(); + + // Interpolate geometry in local space (no transform baked in) — the lerped transform handles positioning + let matched_count = source_subpaths.len().min(target_subpaths.len()); + let extra_source = source_subpaths.split_off(matched_count); + let extra_target = target_subpaths.split_off(matched_count); - // Skip if the path has no segments else get the point at the start of the path. - let Some(start) = target_path.segments().next().map(|element| element.start()) else { continue }; + let mut point_id = PointId::ZERO; + let mut segment_id = SegmentId::ZERO; - for element in target_path.elements_mut() { - match element { - PathEl::MoveTo(point) => *point = start.lerp(*point, time), - PathEl::LineTo(point) => *point = start.lerp(*point, time), - PathEl::QuadTo(point, point1) => { - *point = start.lerp(*point, time); - *point1 = start.lerp(*point1, time); + for ((mut source_manips, source_closed), (mut target_manips, target_closed)) in source_subpaths.into_iter().zip(target_subpaths) { + if source_manips.is_empty() || target_manips.is_empty() { + continue; + } + + // Align manipulator counts by subdividing the last segment of the shorter subpath + let source_count = source_manips.len(); + let target_count = target_manips.len(); + for _ in 0..target_count.saturating_sub(source_count) { + subdivide_last_manipulator_segment(&mut source_manips, source_closed); + } + for _ in 0..source_count.saturating_sub(target_count) { + subdivide_last_manipulator_segment(&mut target_manips, target_closed); + } + + // Build interpolated manipulator groups + let mut interpolated: Vec> = source_manips + .iter() + .zip(target_manips.iter()) + .map(|(s, t)| ManipulatorGroup { + anchor: s.anchor.lerp(t.anchor, time), + in_handle: None, + out_handle: None, + id: PointId::ZERO, + }) + .collect(); + + // Interpolate handles per segment, preserving handle type when source and target match + let segment_count = if source_closed { source_manips.len() } else { source_manips.len().saturating_sub(1) }; + for segment_index in 0..segment_count { + let next_index = (segment_index + 1) % source_manips.len(); + + let source_out = source_manips[segment_index].out_handle; + let source_in = source_manips[next_index].in_handle; + let target_out = target_manips[segment_index].out_handle; + let target_in = target_manips[next_index].in_handle; + + match (source_out, source_in, target_out, target_in) { + // Both linear — no handles needed + (None, None, None, None) => {} + // Both cubic — lerp handle pairs directly + (Some(s_out), Some(s_in), Some(t_out), Some(t_in)) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time)); + interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time)); + } + // Both quadratic with handle in the same position — lerp the single handle + (Some(s_out), None, Some(t_out), None) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(t_out, time)); + } + (None, Some(s_in), None, Some(t_in)) => { + interpolated[next_index].in_handle = Some(s_in.lerp(t_in, time)); + } + // Linear vs. quadratic — elevate the linear side to a zero-length quadratic in the matching position + (None, None, Some(t_out), None) => { + interpolated[segment_index].out_handle = Some(source_manips[segment_index].anchor.lerp(t_out, time)); + } + (None, None, None, Some(t_in)) => { + interpolated[next_index].in_handle = Some(source_manips[next_index].anchor.lerp(t_in, time)); } - PathEl::CurveTo(point, point1, point2) => { - *point = start.lerp(*point, time); - *point1 = start.lerp(*point1, time); - *point2 = start.lerp(*point2, time); + (Some(s_out), None, None, None) => { + interpolated[segment_index].out_handle = Some(s_out.lerp(target_manips[segment_index].anchor, time)); + } + (None, Some(s_in), None, None) => { + interpolated[next_index].in_handle = Some(s_in.lerp(target_manips[next_index].anchor, time)); + } + // Mismatched types — promote both to cubic and lerp + _ => { + let (s_h1, s_h2) = promote_handles_to_cubic(source_manips[segment_index].anchor, source_out, source_in, source_manips[next_index].anchor); + let (t_h1, t_h2) = promote_handles_to_cubic(target_manips[segment_index].anchor, target_out, target_in, target_manips[next_index].anchor); + interpolated[segment_index].out_handle = Some(s_h1.lerp(t_h1, time)); + interpolated[next_index].in_handle = Some(s_h2.lerp(t_h2, time)); } - PathEl::ClosePath => {} } } - vector.append_bezpath(target_path); + + push_manipulators_to_vector(&mut vector, &interpolated, source_closed, &mut point_id, &mut segment_id); + } + + // Deal with unmatched extra source subpaths by collapsing them toward their end point + for (mut manips, closed) in extra_source { + let Some(end) = manips.last().map(|m| m.anchor) else { continue }; + + for manip in &mut manips { + manip.anchor = manip.anchor.lerp(end, time); + manip.in_handle = manip.in_handle.map(|h| h.lerp(end, time)); + manip.out_handle = manip.out_handle.map(|h| h.lerp(end, time)); + } + + push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id); + } + + // Deal with unmatched extra target subpaths by expanding them from their start point + for (mut manips, closed) in extra_target { + let Some(start) = manips.first().map(|m| m.anchor) else { continue }; + + for manip in &mut manips { + manip.anchor = start.lerp(manip.anchor, time); + manip.in_handle = manip.in_handle.map(|h| start.lerp(h, time)); + manip.out_handle = manip.out_handle.map(|h| start.lerp(h, time)); + } + + push_manipulators_to_vector(&mut vector, &manips, closed, &mut point_id, &mut segment_id); } Table::new_from_row(TableRow { element: vector, + transform: lerped_transform, alpha_blending: vector_alpha_blending, ..Default::default() }) @@ -2752,12 +3026,15 @@ mod test { second_rectangle.transform *= DAffine2::from_translation((-100., -100.).into()); rectangles.push(second_rectangle); - let morphed = super::morph(Footprint::default(), rectangles, 0.5).await; - let element = morphed.iter().next().unwrap().element; + let morphed = super::morph(Footprint::default(), rectangles, 0.5, false, InterpolationDistribution::default(), Table::default()).await; + let row = morphed.iter().next().unwrap(); + // Geometry stays in local space (original rectangle coordinates) assert_eq!( - &element.point_domain.positions()[..4], - vec![DVec2::new(-50., -50.), DVec2::new(50., -50.), DVec2::new(50., 50.), DVec2::new(-50., 50.)] + &row.element.point_domain.positions()[..4], + vec![DVec2::new(0., 0.), DVec2::new(100., 0.), DVec2::new(100., 100.), DVec2::new(0., 100.)] ); + // The interpolated transform carries the midpoint translation (approximate due to arc-length parameterization) + assert!((row.transform.translation - DVec2::new(-50., -50.)).length() < 1e-3); } #[track_caller] diff --git a/website/content/features.md b/website/content/features.md index 8c08e86494..f7749065b3 100644 --- a/website/content/features.md +++ b/website/content/features.md @@ -138,7 +138,7 @@ Marrying vector and raster under one roof enables both art forms to complement e All-around performance optimizations -
+
Blend tool to morph between shapes