Convert the node catalog to rank-polymorphic kernels, materialize stored values as ranked wires, and display wire rank in the graph

Co-authored-by:    Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
Keavon Chambers
2026-07-20 21:27:13 -07:00
committed by Dennis Kobert
parent 83cfd0225a
commit 04d6c0d5cf
55 changed files with 1781 additions and 995 deletions

View File

@@ -6,13 +6,24 @@ use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::tool_prelude::*;
use glam::{Affine2, DAffine2, Vec2};
use graph_craft::document::NodeId;
use graphene_std::animation::RealTimeMode;
use graphene_std::blending::BlendMode;
use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY;
use graphene_std::gradient::Gradient;
use graphene_std::list::List;
use graphene_std::list::{Item, List};
use graphene_std::raster::{
CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
};
use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::vector::Vector;
use graphene_std::vector::style::{FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType};
use graphene_std::text::TextAlign;
use graphene_std::text_nodes::StringCapitalization;
use graphene_std::transform::{ReferencePoint, ScaleType};
use graphene_std::vector::misc::{
ArcType, BooleanOperation, BoxCorners, CentroidType, ExtrudeJoiningAlgorithm, GridType, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, SpiralType,
};
use graphene_std::vector::style::{DashPattern, FillChoice, FillChoiceUI, GradientSpreadMethod, GradientType, PaintOrder, StrokeAlign, StrokeCap, StrokeJoin};
use graphene_std::vector::{QRCodeErrorCorrectionLevel, Vector};
use graphene_std::{Artboard, Color, Graphic};
use std::any::Any;
use std::sync::Arc;
@@ -195,24 +206,103 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<Gradient>,
List<String>,
List<f64>,
List<u8>,
List<f32>,
List<u32>,
List<u64>,
List<i32>,
List<i64>,
List<bool>,
List<DVec2>,
List<DAffine2>,
List<BlendMode>,
List<GradientType>,
List<GradientSpreadMethod>,
List<DashPattern>,
List<BoxCorners>,
List<StrokeJoin>,
List<StrokeAlign>,
List<StrokeCap>,
List<PaintOrder>,
List<MergeByDistanceAlgorithm>,
List<ExtrudeJoiningAlgorithm>,
List<PointSpacingType>,
List<StringCapitalization>,
List<LuminanceCalculation>,
List<RedGreenBlue>,
List<RedGreenBlueAlpha>,
List<RelativeAbsolute>,
List<SelectiveColorChoice>,
List<XY>,
List<ScaleType>,
List<ReferencePoint>,
List<CentroidType>,
List<BooleanOperation>,
List<NoiseType>,
List<FractalType>,
List<CellularDistanceFunction>,
List<CellularReturnType>,
List<DomainWarpType>,
List<RealTimeMode>,
List<GridType>,
List<ArcType>,
List<SpiralType>,
List<TextAlign>,
List<QRCodeErrorCorrectionLevel>,
List<InterpolationDistribution>,
List<RowsOrColumns>,
Artboard,
Graphic,
Vector,
Raster<CPU>,
Raster<GPU>,
Color,
Gradient,
String,
f64,
f32,
u32,
u64,
i32,
i64,
bool,
String,
Option<f64>,
DVec2,
DAffine2,
BlendMode,
GradientType,
GradientSpreadMethod,
DashPattern,
BoxCorners,
StrokeJoin,
StrokeAlign,
StrokeCap,
PaintOrder,
MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm,
PointSpacingType,
StringCapitalization,
LuminanceCalculation,
RedGreenBlue,
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
XY,
ScaleType,
ReferencePoint,
CentroidType,
BooleanOperation,
NoiseType,
FractalType,
CellularDistanceFunction,
CellularReturnType,
DomainWarpType,
RealTimeMode,
GridType,
ArcType,
SpiralType,
TextAlign,
QRCodeErrorCorrectionLevel,
InterpolationDistribution,
RowsOrColumns,
])
}
@@ -250,6 +340,57 @@ trait TableItemLayout {
}
}
impl<T: TableItemLayout> TableItemLayout for Item<T> {
fn type_name() -> &'static str {
T::type_name()
}
fn identifier(&self) -> String {
self.element().identifier()
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
if let Some(step) = data.desired_path.get(data.current_depth).cloned() {
match step {
PathStep::Element(_) => {
data.current_depth += 1;
let result = self.element().layout_with_breadcrumb(data);
data.current_depth -= 1;
return result;
}
PathStep::Attribute { key, .. } => {
if let Some(any) = self.attributes().get_any(&key) {
data.current_depth += 1;
if let Some(result) = drilldown_attribute_layout(any, data) {
data.current_depth -= 1;
return result;
}
data.current_depth -= 1;
warn!("Drilldown unsupported for attribute {key:?}");
}
data.desired_path.truncate(data.current_depth);
}
}
}
let attribute_keys: Vec<String> = self.attributes().keys().map(str::to_string).collect();
// A single element, so no leading ID column, unlike the `List` table
let mut values = vec![self.element().value_widget(PathStep::Element(0), data)];
for key in &attribute_keys {
let target = PathStep::Attribute { row: 0, key: key.clone() };
let widget = self.attributes().get_any(key).and_then(|any| dispatch_value_widget(any, target, data)).unwrap_or_else(|| {
let text = self.attributes().display_value(key, display_value_override).unwrap_or_else(|| "-".to_string());
TextLabel::new(text).narrow(true).widget_instance()
});
values.push(widget);
}
let mut column_names = vec!["element"];
column_names.extend(attribute_keys.iter().map(|s| s.as_str()));
vec![LayoutGroup::table(vec![column_headings(&column_names), values], false)]
}
}
impl<T: TableItemLayout> TableItemLayout for List<T> {
fn type_name() -> &'static str {
"List"
@@ -328,6 +469,46 @@ impl TableItemLayout for Artboard<'_> {
}
}
impl TableItemLayout for DashPattern {
fn type_name() -> &'static str {
"DashPattern"
}
fn identifier(&self) -> String {
"DashPattern".to_string()
}
// The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.value_page(data)
}
// Label the spreadsheet's element button with the inner list's identifier, like Artboard
fn value_widget(&self, target: PathStep, data: &LayoutData) -> WidgetInstance {
self.0.value_widget(target, data)
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.0.layout_with_breadcrumb(data)
}
}
impl TableItemLayout for BoxCorners {
fn type_name() -> &'static str {
"BoxCorners"
}
fn identifier(&self) -> String {
"BoxCorners".to_string()
}
// The wrapping `Item` already contributes the breadcrumb; the inner list supplies the next level
fn layout_with_breadcrumb(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.value_page(data)
}
// Label the spreadsheet's element button with the inner list's identifier, like Artboard
fn value_widget(&self, target: PathStep, data: &LayoutData) -> WidgetInstance {
self.0.value_widget(target, data)
}
fn value_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
self.0.layout_with_breadcrumb(data)
}
}
impl TableItemLayout for Graphic<'_> {
fn type_name() -> &'static str {
"Graphic"
@@ -520,7 +701,7 @@ impl TableItemLayout for Raster<GPU> {
format!("Raster ({} x {})", self.data().width(), self.data().height())
}
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
let widgets = vec![TextLabel::new("Raster is a texture on the GPU and cannot currently be displayed here").widget_instance()];
let widgets = vec![TextLabel::new("This raster data is a texture on the GPU. It currently cannot be displayed here.").widget_instance()];
vec![LayoutGroup::row(widgets)]
}
}
@@ -593,6 +774,21 @@ impl TableItemLayout for u8 {
}
}
impl TableItemLayout for f32 {
fn type_name() -> &'static str {
"Number (f32)"
}
fn identifier(&self) -> String {
format!("{self}")
}
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
])]
}
}
impl TableItemLayout for u32 {
fn type_name() -> &'static str {
"Number (u32)"
@@ -608,6 +804,37 @@ impl TableItemLayout for u32 {
}
}
impl TableItemLayout for i32 {
fn type_name() -> &'static str {
"Number (i32)"
}
fn identifier(&self) -> String {
format!("{self}")
}
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
])]
}
}
impl TableItemLayout for i64 {
fn type_name() -> &'static str {
"Number (i64)"
}
fn identifier(&self) -> String {
format!("{self}")
}
// Values fall back to the default drill-in button (labeled via `identifier`); the value page shows the rich `NumberInput`.
// TODO: Make this robust for large i64 values that don't fit in f64 (beyond roughly 2^53), as with u64.
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![
NumberInput::new(Some(*self as f64)).disabled(true).max_width(220).display_decimal_places(20).widget_instance(),
])]
}
}
impl TableItemLayout for u64 {
fn type_name() -> &'static str {
"Number (u64)"
@@ -734,45 +961,73 @@ impl TableItemLayout for Affine2 {
}
}
impl TableItemLayout for BlendMode {
fn type_name() -> &'static str {
"BlendMode"
}
fn identifier(&self) -> String {
self.to_string()
}
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
TextLabel::new(self.to_string()).narrow(true).widget_instance()
}
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![self.value_widget(PathStep::Element(0), _data)])]
// Choice enums all display as their variant's label, shown inline as a plain text widget
macro_rules! impl_table_item_layout_for_choice_enum {
($($ty:ty),* $(,)?) => {
$(
impl TableItemLayout for $ty {
fn type_name() -> &'static str {
stringify!($ty)
}
fn identifier(&self) -> String {
self.to_string()
}
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
TextLabel::new(self.to_string()).narrow(true).widget_instance()
}
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![self.value_widget(PathStep::Element(0), _data)])]
}
}
)*
}
}
impl_table_item_layout_for_choice_enum!(
BlendMode,
GradientType,
GradientSpreadMethod,
StrokeJoin,
StrokeAlign,
StrokeCap,
PaintOrder,
MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm,
PointSpacingType,
StringCapitalization,
LuminanceCalculation,
RedGreenBlue,
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
XY,
ScaleType,
CentroidType,
BooleanOperation,
NoiseType,
FractalType,
CellularDistanceFunction,
CellularReturnType,
DomainWarpType,
RealTimeMode,
GridType,
ArcType,
SpiralType,
TextAlign,
QRCodeErrorCorrectionLevel,
InterpolationDistribution,
RowsOrColumns,
);
impl TableItemLayout for GradientType {
// ReferencePoint is not a choice enum with display labels, so its variant name serves as the label
impl TableItemLayout for ReferencePoint {
fn type_name() -> &'static str {
"GradientType"
"ReferencePoint"
}
fn identifier(&self) -> String {
self.to_string()
format!("{self:?}")
}
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
TextLabel::new(self.to_string()).narrow(true).widget_instance()
}
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![self.value_widget(PathStep::Element(0), _data)])]
}
}
impl TableItemLayout for GradientSpreadMethod {
fn type_name() -> &'static str {
"GradientSpreadMethod"
}
fn identifier(&self) -> String {
self.to_string()
}
fn value_widget(&self, _target: PathStep, _data: &LayoutData) -> WidgetInstance {
TextLabel::new(self.to_string()).narrow(true).widget_instance()
TextLabel::new(self.identifier()).narrow(true).widget_instance()
}
fn value_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
vec![LayoutGroup::row(vec![self.value_widget(PathStep::Element(0), _data)])]
@@ -915,9 +1170,7 @@ macro_rules! known_item_types {
List<Color>,
List<Gradient>,
List<String>,
List<NodeId>,
List<f64>,
List<u8>,
Gradient,
Color,
NodeId,
@@ -927,9 +1180,12 @@ macro_rules! known_item_types {
Vec2,
Option<f64>,
f64,
f32,
u8,
u32,
u64,
i32,
i64,
bool,
String,
Vector,
@@ -937,6 +1193,42 @@ macro_rules! known_item_types {
Raster<GPU>,
Graphic,
Artboard,
DashPattern,
BoxCorners,
BlendMode,
GradientType,
GradientSpreadMethod,
StrokeJoin,
StrokeAlign,
StrokeCap,
PaintOrder,
MergeByDistanceAlgorithm,
ExtrudeJoiningAlgorithm,
PointSpacingType,
StringCapitalization,
LuminanceCalculation,
RedGreenBlue,
RedGreenBlueAlpha,
RelativeAbsolute,
SelectiveColorChoice,
XY,
ScaleType,
ReferencePoint,
CentroidType,
BooleanOperation,
NoiseType,
FractalType,
CellularDistanceFunction,
CellularReturnType,
DomainWarpType,
RealTimeMode,
GridType,
ArcType,
SpiralType,
TextAlign,
QRCodeErrorCorrectionLevel,
InterpolationDistribution,
RowsOrColumns,
);
};
}

View File

@@ -2748,7 +2748,7 @@ impl DocumentMessageHandler {
}
/// For each selected layer, splits its fill and stroke into two stacked layers connected
/// to a shared `Solidify Stroke` node via two `Index Elements` nodes (indices 0 and 1).
/// to a shared `Solidify Stroke` node via two `Item at Index` nodes (indices 0 and 1).
/// Layers with only a stroke get just a `Solidify Stroke` added.
/// Layers with only a fill, or neither, are left untouched.
fn handle_expand_fill_stroke_on_selected_layers(&mut self, responses: &mut VecDeque<Message>) {
@@ -4318,4 +4318,41 @@ mod document_message_handler_tests {
Dist: {distance} (should be < 1)"
);
}
// Grouping choreography transiently disconnects the stack wire, and the stored default for that connector
// must stay an empty list rather than any value which materializes as a one-element phantom in the stack
#[tokio::test]
async fn grouping_adds_no_phantom_element_to_the_stack() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
editor.drag_tool(ToolType::Rectangle, 0., 0., 100., 100., ModifierKeys::empty()).await;
editor
.handle_message(DocumentMessage::GroupSelectedLayers {
group_folder_type: GroupFolderType::Layer,
})
.await;
let instrumented = editor.eval_graph().await.unwrap();
// An empty stack base is never served: `Extend` maps no lane onto it, so the monitor records nothing rather than an empty list.
// A base that wrongly carried a phantom element would therefore show up as a recorded row, which this catches.
// The `news` guard below is what keeps both assertions honest, since a wrong `Output` type empties every record.
let base_lengths: Vec<usize> = instrumented
.grab_all_input_as::<graphene_std::graphic::extend::BaseInput<graphene_std::Graphic>, graphene_std::list::List<graphene_std::Graphic>>(&editor.runtime)
.map(|base| base.len())
.collect();
assert!(base_lengths.iter().all(|&len| len == 0), "Every stack base should be empty, found lengths {base_lengths:?}");
let news: Vec<graphene_std::list::List<graphene_std::Graphic>> = instrumented
.grab_all_input_as::<graphene_std::graphic::extend::NewInput<graphene_std::Graphic>, graphene_std::list::List<graphene_std::Graphic>>(&editor.runtime)
.collect();
assert!(!news.is_empty(), "Instrumentation should have recorded at least one stacked element list");
let phantom_count = news
.iter()
.flat_map(|new| new.iter_element_values())
.filter(|graphic| matches!(graphic, graphene_std::Graphic::None))
.count();
assert_eq!(phantom_count, 0, "No stacked element should be a phantom None graphic");
}
}

View File

@@ -486,7 +486,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
inputs: vec![NodeInput::import(generic!(T), 4)],
..Default::default()
},
// 1: Count Elements (number of subpaths)
// 1: List Length (number of subpaths)
DocumentNode {
implementation: DocumentNodeImplementation::ProtoNode(vector::list_length::IDENTIFIER),
inputs: vec![NodeInput::node(NodeId(0), 0)],
@@ -578,7 +578,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
NodeInput::node(NodeId(14), 0),
NodeInput::value(TaggedValue::Bool(false), false),
NodeInput::import(concrete!(vector::misc::InterpolationDistribution), 3),
NodeInput::import(generic!(T), 4),
NodeInput::import(concrete!(Vector), 4),
],
..Default::default()
},
@@ -637,7 +637,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
},
..Default::default()
},
// 1: Count Elements
// 1: List Length
DocumentNodeMetadata {
persistent_metadata: DocumentNodePersistentMetadata {
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(2, 2)),
@@ -1332,13 +1332,13 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
implementation: DocumentNodeImplementation::ProtoNode(text_nodes::regex::regex_find::IDENTIFIER),
..Default::default()
},
// Node 1: extract_element at index 0, extracts the whole match as a bare String (drops the item's start/end/name attributes since the unwrapped String can't carry them)
// Node 1: item_at_index at index 0, extracts the whole match as a bare String (drops the item's start/end/name attributes since the unwrapped String can't carry them)
DocumentNode {
inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)],
implementation: DocumentNodeImplementation::ProtoNode(graphic::item_at_index::IDENTIFIER),
..Default::default()
},
// Node 2: omit_element at index 0, returns the capture group items as a List<String>, preserving each item's start/end/name attributes
// Node 2: remove_at_index at index 0, returns the capture group items as a List<String>, preserving each item's start/end/name attributes
DocumentNode {
inputs: vec![NodeInput::node(NodeId(0), 0), NodeInput::value(TaggedValue::F64(0.), false)],
implementation: DocumentNodeImplementation::ProtoNode(graphic::remove_at_index::IDENTIFIER),
@@ -1423,7 +1423,7 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
exports: vec![NodeInput::node(NodeId(1), 0)],
nodes: vec![
DocumentNode {
inputs: vec![NodeInput::import(concrete!(List<Vector>), 0)],
inputs: vec![NodeInput::import(generic!(T), 0)],
implementation: DocumentNodeImplementation::ProtoNode(memo::monitor::IDENTIFIER),
call_argument: generic!(T),
skip_deduplication: true,
@@ -2180,3 +2180,33 @@ impl DocumentNodeDefinition {
self.node_template_input_override(self.node_template.document_node.inputs.clone().into_iter().map(Some))
}
}
#[cfg(test)]
mod test {
use super::resolve_network_node_type;
use crate::test_utils::test_prelude::*;
use graph_craft::document::NodeId;
// Guards the embedded Map body chain (Read Vector -> Extract Transform -> Decompose Translation -> As Vector) against registry drift
#[tokio::test]
async fn origins_to_polyline_resolves_and_evaluates() {
let mut editor = EditorTestUtils::create();
editor.new_document().await;
editor.draw_rect(0., 0., 10., 10.).await;
let layer = editor.active_document().metadata().all_layers().next().expect("drawing a rectangle should create a layer");
let node_id = NodeId::new();
let node_template = resolve_network_node_type("Origins to Polyline")
.expect("the Origins to Polyline definition should exist")
.default_node_template();
editor
.handle_message(NodeGraphMessage::InsertNode {
node_id,
node_template: Box::new(node_template),
})
.await;
editor.handle_message(NodeGraphMessage::MoveNodeToChainStart { node_id, parent: layer }).await;
editor.eval_graph().await.expect("the Origins to Polyline chain should type-resolve and evaluate");
}
}

View File

@@ -1179,6 +1179,8 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
data_type: self.wire_in_progress_type,
thick: false,
dashed: false,
is_list: false,
center_path_string: String::new(),
};
responses.add(FrontendMessage::UpdateWirePathInProgress { wire_path: Some(wire_path) });
}
@@ -1431,7 +1433,7 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
return None;
}
let (wire, is_stack) = network_interface.vector_wire_from_input(&input, preferences.graph_wire_style, selection_network_path)?;
let (wire, _center_line, is_stack) = network_interface.vector_wire_from_input(&input, preferences.graph_wire_style, selection_network_path)?;
let node_bbox = kurbo::Rect::new(node_bbox[0].x, node_bbox[0].y, node_bbox[1].x, node_bbox[1].y).to_path(DEFAULT_ACCURACY);
let inside = bezpath_is_inside_bezpath(&wire, &node_bbox, None, None);
@@ -1726,7 +1728,13 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
continue;
};
if node_bbox[1].x >= document_bbox[0].x && node_bbox[0].x <= document_bbox[1].x && node_bbox[1].y >= document_bbox[0].y && node_bbox[0].y <= document_bbox[1].y {
// Expand the cull box by a grid cell so a node stays rendered until its connectors, which reach beyond its bounding box, also leave the viewport
let cull_margin = 24.;
if node_bbox[1].x + cull_margin >= document_bbox[0].x
&& node_bbox[0].x - cull_margin <= document_bbox[1].x
&& node_bbox[1].y + cull_margin >= document_bbox[0].y
&& node_bbox[0].y - cull_margin <= document_bbox[1].y
{
nodes.push(*node_id);
}
for error in &network_interface.resolved_types.node_graph_errors {
@@ -2168,7 +2176,37 @@ impl<'a> MessageHandler<NodeGraphMessage, NodeGraphMessageContext<'a>> for NodeG
responses.add(NodeGraphMessage::SendGraph);
}
NodeGraphMessage::UpdateTypes { resolved_types, node_graph_errors } => {
// Hidden passthrough nodes let a wire borrow its color and rank from an upstream node, so any type change can restyle wires whose own node is unchanged.
// Compare each displayed wire's style (color, rank) across the update and unload only those that changed, so value-only recompiles keep their built wire paths.
let types_changed = !resolved_types.add.is_empty() || !resolved_types.remove.is_empty();
let wire_style = |network_interface: &mut NodeNetworkInterface, input: &InputConnector| {
network_interface.upstream_output_connector(input, breadcrumb_network_path).map(|output| {
let output_type = network_interface.output_type(&output, breadcrumb_network_path);
(output_type.displayed_type(), output_type.is_list())
})
};
let styles_before = types_changed.then(|| {
network_interface
.node_graph_input_connectors(breadcrumb_network_path)
.into_iter()
.map(|input| {
let style = wire_style(network_interface, &input);
(input, style)
})
.collect::<Vec<_>>()
});
network_interface.resolved_types.update(resolved_types, node_graph_errors);
if let Some(styles_before) = styles_before {
for (input, style_before) in styles_before {
if wire_style(network_interface, &input) != style_before {
network_interface.unload_wire(&input, breadcrumb_network_path);
}
}
}
responses.add(NodeGraphMessage::SendGraph);
}
NodeGraphMessage::UpdateActionButtons => {
if selection_network_path == breadcrumb_network_path {

View File

@@ -240,6 +240,12 @@ pub(crate) fn property_from_type(
// For all other types, use TypeId-based matching
_ => {
use std::any::TypeId;
// The compiler peels a rank-0 `Item` cell to its element before this arm runs, so widgets dispatch on the bare element `T`
fn id_is<T: 'static>(id: TypeId) -> bool {
id == TypeId::of::<T>()
}
match concrete_type.id {
// ===============
// PRIMITIVE TYPES
@@ -265,48 +271,48 @@ pub(crate) fn property_from_type(
// ============
// STRUCT TYPES
// ============
Some(x) if x == TypeId::of::<Font>() => font_widget(default_info),
Some(x) if x == TypeId::of::<Footprint>() => footprint_widget(default_info, &mut extra_widgets),
Some(x) if x == TypeId::of::<Box<VectorModification>>() => vector_modification_widget(default_info).into(),
Some(x) if x == TypeId::of::<Image<Color>>() => image_data_widget(default_info).into(),
Some(x) if id_is::<Font>(x) => font_widget(default_info),
Some(x) if id_is::<Footprint>(x) => footprint_widget(default_info, &mut extra_widgets),
Some(x) if id_is::<Box<VectorModification>>(x) => vector_modification_widget(default_info).into(),
Some(x) if id_is::<Image<Color>>(x) => image_data_widget(default_info).into(),
// ===============================
// MANUALLY IMPLEMENTED ENUM TYPES
// ===============================
Some(x) if x == TypeId::of::<ReferencePoint>() => reference_point_widget(default_info, false).into(),
Some(x) if x == TypeId::of::<BlendMode>() => blend_mode_widget(default_info),
Some(x) if id_is::<ReferencePoint>(x) => reference_point_widget(default_info, false).into(),
Some(x) if id_is::<BlendMode>(x) => blend_mode_widget(default_info),
// =========================
// AUTO-GENERATED ENUM TYPES
// =========================
Some(x) if x == TypeId::of::<GradientType>() => enum_choice::<GradientType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<GradientSpreadMethod>() => enum_choice::<GradientSpreadMethod>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RealTimeMode>() => enum_choice::<RealTimeMode>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RedGreenBlue>() => enum_choice::<RedGreenBlue>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<RedGreenBlueAlpha>() => enum_choice::<RedGreenBlueAlpha>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<XY>() => enum_choice::<XY>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<StringCapitalization>() => enum_choice::<StringCapitalization>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<NoiseType>() => enum_choice::<NoiseType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<FractalType>() => enum_choice::<FractalType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if x == TypeId::of::<CellularDistanceFunction>() => enum_choice::<CellularDistanceFunction>().for_socket(default_info).disabled(false).property_row(),
Some(x) if x == TypeId::of::<CellularReturnType>() => enum_choice::<CellularReturnType>().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::<GridType>() => enum_choice::<GridType>().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::<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::<RowsOrColumns>() => enum_choice::<RowsOrColumns>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<TextAlign>() => enum_choice::<TextAlign>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<MergeByDistanceAlgorithm>() => enum_choice::<MergeByDistanceAlgorithm>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<ExtrudeJoiningAlgorithm>() => enum_choice::<ExtrudeJoiningAlgorithm>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<PointSpacingType>() => enum_choice::<PointSpacingType>().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::<LuminanceCalculation>() => enum_choice::<LuminanceCalculation>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<QRCodeErrorCorrectionLevel>() => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<ScaleType>() => enum_choice::<ScaleType>().for_socket(default_info).property_row(),
Some(x) if x == TypeId::of::<InterpolationDistribution>() => enum_choice::<InterpolationDistribution>().for_socket(default_info).property_row(),
Some(x) if id_is::<GradientType>(x) => enum_choice::<GradientType>().for_socket(default_info).property_row(),
Some(x) if id_is::<GradientSpreadMethod>(x) => enum_choice::<GradientSpreadMethod>().for_socket(default_info).property_row(),
Some(x) if id_is::<RealTimeMode>(x) => enum_choice::<RealTimeMode>().for_socket(default_info).property_row(),
Some(x) if id_is::<RedGreenBlue>(x) => enum_choice::<RedGreenBlue>().for_socket(default_info).property_row(),
Some(x) if id_is::<RedGreenBlueAlpha>(x) => enum_choice::<RedGreenBlueAlpha>().for_socket(default_info).property_row(),
Some(x) if id_is::<XY>(x) => enum_choice::<XY>().for_socket(default_info).property_row(),
Some(x) if id_is::<StringCapitalization>(x) => enum_choice::<StringCapitalization>().for_socket(default_info).property_row(),
Some(x) if id_is::<NoiseType>(x) => enum_choice::<NoiseType>().for_socket(default_info).property_row(),
Some(x) if id_is::<FractalType>(x) => enum_choice::<FractalType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<CellularDistanceFunction>(x) => enum_choice::<CellularDistanceFunction>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<CellularReturnType>(x) => enum_choice::<CellularReturnType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<DomainWarpType>(x) => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<RelativeAbsolute>(x) => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<GridType>(x) => enum_choice::<GridType>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeJoin>(x) => enum_choice::<StrokeJoin>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeAlign>(x) => enum_choice::<StrokeAlign>().for_socket(default_info).property_row(),
Some(x) if id_is::<PaintOrder>(x) => enum_choice::<PaintOrder>().for_socket(default_info).property_row(),
Some(x) if id_is::<ArcType>(x) => enum_choice::<ArcType>().for_socket(default_info).property_row(),
Some(x) if id_is::<RowsOrColumns>(x) => enum_choice::<RowsOrColumns>().for_socket(default_info).property_row(),
Some(x) if id_is::<TextAlign>(x) => enum_choice::<TextAlign>().for_socket(default_info).property_row(),
Some(x) if id_is::<MergeByDistanceAlgorithm>(x) => enum_choice::<MergeByDistanceAlgorithm>().for_socket(default_info).property_row(),
Some(x) if id_is::<ExtrudeJoiningAlgorithm>(x) => enum_choice::<ExtrudeJoiningAlgorithm>().for_socket(default_info).property_row(),
Some(x) if id_is::<PointSpacingType>(x) => enum_choice::<PointSpacingType>().for_socket(default_info).property_row(),
Some(x) if id_is::<BooleanOperation>(x) => enum_choice::<BooleanOperation>().for_socket(default_info).property_row(),
Some(x) if id_is::<CentroidType>(x) => enum_choice::<CentroidType>().for_socket(default_info).property_row(),
Some(x) if id_is::<LuminanceCalculation>(x) => enum_choice::<LuminanceCalculation>().for_socket(default_info).property_row(),
Some(x) if id_is::<QRCodeErrorCorrectionLevel>(x) => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(),
Some(x) if id_is::<ScaleType>(x) => enum_choice::<ScaleType>().for_socket(default_info).property_row(),
Some(x) if id_is::<InterpolationDistribution>(x) => enum_choice::<InterpolationDistribution>().for_socket(default_info).property_row(),
// =====
// OTHER
// =====
@@ -2370,10 +2376,14 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
let mut unit_suffix = None;
let input_type = match implementation {
DocumentNodeImplementation::ProtoNode(proto_node_identifier) => 'early_return: {
// Clone to end the `network_interface` borrow held via `implementation`, freeing the mutable borrow `input_type` needs below
let proto_node_identifier = proto_node_identifier.clone();
let mut default_type = None;
if let Some(field) = graphene_std::registry::NODE_METADATA
.lock()
.unwrap()
.get(proto_node_identifier)
.get(&proto_node_identifier)
.and_then(|metadata| metadata.fields.get(input_index))
{
number_options = NumberOptions {
@@ -2386,12 +2396,14 @@ pub(crate) fn generate_node_properties(node_id: NodeId, context: &mut NodeProper
display_decimal_places = field.number_display_decimal_places;
unit_suffix = field.unit;
step = field.number_step;
if let Some(ref default) = field.default_type {
break 'early_return default.clone();
}
default_type = field.default_type.clone();
}
let Some(implementations) = &interpreted_executor::node_registry::NODE_REGISTRY.get(proto_node_identifier) else {
if let Some(default) = default_type {
break 'early_return default;
}
let Some(implementations) = &interpreted_executor::node_registry::NODE_REGISTRY.get(&proto_node_identifier) else {
log::error!("Could not get implementation for protonode {proto_node_identifier:?}");
return Vec::new();
};

View File

@@ -15,7 +15,7 @@ use crate::messages::portfolio::document::node_graph::document_node_definitions:
use crate::messages::portfolio::document::node_graph::utility_types::{Direction, FrontendClickTargets, FrontendGraphDataType, FrontendGraphInput, FrontendGraphOutput};
use crate::messages::portfolio::document::overlays::utility_functions::text_width;
use crate::messages::portfolio::document::utility_types::network_interface::resolved_types::ResolvedDocumentNodeTypes;
use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_vector_wire};
use crate::messages::portfolio::document::utility_types::wires::{GraphWireStyle, WirePath, WirePathUpdate, build_thick_wire_center_line, build_vector_wire};
use crate::messages::tool::common_functionality::graph_modification_utils;
use crate::messages::tool::tool_messages::tool_prelude::NumberInputMode;
use deserialization::deserialize_node_persistent_metadata;
@@ -2498,14 +2498,20 @@ impl NodeNetworkInterface {
let vertical_start: bool = upstream_output.node_id().is_some_and(|node_id| self.is_layer(&node_id, network_path));
let thick = vertical_end && vertical_start;
let vector_wire = build_vector_wire(output_position, input_position, vertical_start, vertical_end, graph_wire_style);
let center_line = build_thick_wire_center_line(output_position, input_position, vertical_start, vertical_end);
let path_string = vector_wire.to_svg();
let data_type = self.input_type(&input, network_path).displayed_type();
let center_path_string = center_line.to_svg();
let input_type = self.input_type(&input, network_path);
let data_type = input_type.displayed_type();
let is_list = input_type.is_list();
let wire_path_update = Some(WirePath {
path_string,
data_type,
thick,
dashed: false,
is_list,
center_path_string,
});
Some(WirePathUpdate {
@@ -2515,15 +2521,15 @@ impl NodeNetworkInterface {
})
}
/// Returns the vector subpath and a boolean of whether the wire should be thick.
pub fn vector_wire_from_input(&mut self, input: &InputConnector, wire_style: GraphWireStyle, network_path: &[NodeId]) -> Option<(BezPath, bool)> {
/// Returns the wire subpath, its thick center-line subpath, and whether the wire should be thick.
pub fn vector_wire_from_input(&mut self, input: &InputConnector, wire_style: GraphWireStyle, network_path: &[NodeId]) -> Option<(BezPath, BezPath, bool)> {
let Some(input_position) = self.get_input_center(input, network_path) else {
log::error!("Could not get dom rect for wire end: {input:?}");
return None;
};
// An upstream output could not be found, so the wire does not exist, but it should still be loaded as as empty vector
let Some(upstream_output) = self.upstream_output_connector(input, network_path) else {
return Some((BezPath::new(), false));
return Some((BezPath::new(), BezPath::new(), false));
};
let Some(output_position) = self.get_output_center(&upstream_output, network_path) else {
log::error!("Could not get output port for wire start: {:?}", upstream_output);
@@ -2532,21 +2538,29 @@ impl NodeNetworkInterface {
let vertical_end = input.node_id().is_some_and(|node_id| self.is_layer(&node_id, network_path) && input.input_index() == 0);
let vertical_start = upstream_output.node_id().is_some_and(|node_id| self.is_layer(&node_id, network_path));
let thick = vertical_end && vertical_start;
Some((build_vector_wire(output_position, input_position, vertical_start, vertical_end, wire_style), thick))
let vector_wire = build_vector_wire(output_position, input_position, vertical_start, vertical_end, wire_style);
let center_line = build_thick_wire_center_line(output_position, input_position, vertical_start, vertical_end);
Some((vector_wire, center_line, thick))
}
pub fn wire_path_from_input(&mut self, input: &InputConnector, graph_wire_style: GraphWireStyle, dashed: bool, network_path: &[NodeId]) -> Option<WirePath> {
let (vector_wire, thick) = self.vector_wire_from_input(input, graph_wire_style, network_path)?;
let (vector_wire, center_line, thick) = self.vector_wire_from_input(input, graph_wire_style, network_path)?;
let path_string = vector_wire.to_svg();
let data_type = self
let center_path_string = center_line.to_svg();
let (data_type, is_list) = self
.upstream_output_connector(input, network_path)
.map(|output| self.output_type(&output, network_path).displayed_type())
.unwrap_or(FrontendGraphDataType::General);
.map(|output| {
let output_type = self.output_type(&output, network_path);
(output_type.displayed_type(), output_type.is_list())
})
.unwrap_or((FrontendGraphDataType::General, false));
Some(WirePath {
path_string,
data_type,
thick,
dashed,
is_list,
center_path_string,
})
}
@@ -6092,6 +6106,19 @@ impl NodeNetworkInterface {
// Chain is empty: wire the node as the first (and only) entry in the chain
if matches!(current_input, NodeInput::Value { .. }) {
// A node whose exposed primary defaults to no value inherits the layer's content value, so the chain keeps producing the layer's content type
let node_primary = InputConnector::node(*node_id, 0);
let default_is_valueless = self
.input_from_connector(&node_primary, network_path)
.is_some_and(|input| matches!(input, NodeInput::Value { tagged_value, exposed: true } if matches!(**tagged_value, TaggedValue::None)));
if default_is_valueless {
if import {
self.set_input_for_import(&node_primary, current_input.clone(), network_path);
} else {
self.set_input(&node_primary, current_input.clone(), network_path);
}
}
// Wire: [parent] -> [new node]
if import {
self.set_input_for_import(&parent_input, NodeInput::node(*node_id, 0), network_path);

View File

@@ -4,11 +4,7 @@ use graph_craft::document::value::TaggedValue;
use graph_craft::document::{DocumentNodeImplementation, InlineRust, NodeInput};
use graph_craft::proto::{GraphErrorType, GraphErrors};
use graph_craft::{Type, concrete};
use graphene_std::list::List;
use graphene_std::raster_types::{CPU, Raster};
use graphene_std::uuid::NodeId;
use graphene_std::vector::Vector;
use graphene_std::{Artboard, Graphic};
use interpreted_executor::dynamic_executor::{NodeTypes, ResolvedDocumentNodeTypesDelta};
use interpreted_executor::node_registry::NODE_REGISTRY;
@@ -56,28 +52,33 @@ impl TypeSource {
return FrontendGraphDataType::Invalid;
};
match self.compiled_nested_type() {
Some(nested_type) => match TaggedValue::from_type_or_none(nested_type) {
TaggedValue::U32(_) | TaggedValue::U64(_) | TaggedValue::F32(_) | TaggedValue::F64(_) | TaggedValue::DVec2(_) | TaggedValue::F64Array(_) | TaggedValue::DAffine2(_) => {
FrontendGraphDataType::Number
}
TaggedValue::Color(_) => FrontendGraphDataType::Color,
TaggedValue::LegacyGradient(_) | TaggedValue::Gradient(_) => FrontendGraphDataType::Gradient,
TaggedValue::String(_) => FrontendGraphDataType::Typography,
// Types whose `TaggedValue` variant has been removed are routed through `TypeDefault` and identified by the descriptor's type name.
TaggedValue::TypeDefault(td) => match td.name.as_ref() {
n if n == graphene_std::core_types::normalize_type_name(std::any::type_name::<List<Graphic>>()) => FrontendGraphDataType::Graphic,
n if n == graphene_std::core_types::normalize_type_name(std::any::type_name::<List<Artboard>>()) => FrontendGraphDataType::Artboard,
n if n == graphene_std::core_types::normalize_type_name(std::any::type_name::<List<Raster<CPU>>>()) => FrontendGraphDataType::Raster,
n if n == graphene_std::core_types::normalize_type_name(std::any::type_name::<List<Vector>>()) => FrontendGraphDataType::Vector,
n if n == graphene_std::core_types::normalize_type_name(std::any::type_name::<List<String>>()) => FrontendGraphDataType::Typography,
_ => FrontendGraphDataType::General,
},
_ => FrontendGraphDataType::General,
},
Some(nested_type) => FrontendGraphDataType::from_type(nested_type),
None => FrontendGraphDataType::General,
}
}
/// Whether the compiled type is a packed `Record` lane, as opposed to a bare rank-0 value.
pub fn is_list(&self) -> bool {
// `nested_type` peels `Record`, so the rank has to be read off the unpeeled type
fn is_record(ty: &Type) -> bool {
match ty {
Type::Fn(_, output) | Type::Future(output) => is_record(output),
Type::Record(_) => true,
_ => false,
}
}
match self {
TypeSource::Compiled(compiled_type) => is_record(compiled_type),
TypeSource::TaggedValue(value_type) => is_record(value_type),
_ => false,
}
}
/// The element type's identifier name, so semantic type checks can be rank-agnostic.
pub fn compiled_element_name(&self) -> Option<String> {
Some(self.compiled_nested_type()?.identifier_name())
}
pub fn compiled_nested_type(&self) -> Option<&Type> {
match self {
TypeSource::Compiled(compiled_type) => Some(compiled_type.nested_type()),
@@ -206,6 +207,8 @@ impl NodeNetworkInterface {
concrete!(())
}
};
// `TaggedValue::from_type` recurses through `Record` to the element, so a record default already drops to rank 0
TaggedValue::from_type_or_none(&guaranteed_type)
}
@@ -335,12 +338,19 @@ impl NodeNetworkInterface {
pub fn output_type(&mut self, output_connector: &OutputConnector, network_path: &[NodeId]) -> TypeSource {
match output_connector {
OutputConnector::Node { node_id, output_index } => {
// A hidden node is replaced by a passthrough during flattening, so its output carries its primary input's type
if *output_index == 0 && !self.is_visible(node_id, network_path) {
return self.input_type(&InputConnector::node(*node_id, 0), network_path);
}
// First try iterating upstream to the first protonode and try get its compiled type
let Some(implementation) = self.implementation(node_id, network_path) else {
return TypeSource::Error("Could not get implementation");
};
match implementation {
DocumentNodeImplementation::Network(_) => self.input_type(&InputConnector::Export(*output_index), &[network_path, &[*node_id]].concat()),
// The compiler removes passthrough nodes so they resolve no type of their own, but their output carries their primary input's type
DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::ops::passthrough::IDENTIFIER => self.input_type(&InputConnector::node(*node_id, 0), network_path),
DocumentNodeImplementation::ProtoNode(_) => match self.resolved_types.types.get(&[network_path, &[*node_id]].concat()) {
Some(resolved_type) => TypeSource::Compiled(resolved_type.output.clone()),
None => TypeSource::Unknown,

View File

@@ -12,6 +12,12 @@ pub struct WirePath {
pub data_type: FrontendGraphDataType,
pub thick: bool,
pub dashed: bool,
// A rank-1 `List<T>` wire renders as a doubled-up pair of parallel lines to distinguish it from a rank-0 `Item<T>` wire
#[serde(rename = "isList")]
pub is_list: bool,
// A thick wire's center line reaches past the wire into the cleaved connector slots, so it needs its own longer path; empty otherwise
#[serde(rename = "centerPathString")]
pub center_path_string: String,
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -57,6 +63,19 @@ impl GraphWireStyle {
pub fn build_vector_wire(output_position: DVec2, input_position: DVec2, vertical_out: bool, vertical_in: bool, graph_wire_style: GraphWireStyle) -> BezPath {
let grid_spacing = 24.;
// A thick layer-stack wire (vertical at both ends) is skipped across a single straight grid cell where its connectors
// already meet, and otherwise trimmed 3px inward at each end since it overshoots the connectors.
let (output_position, input_position) = if vertical_out && vertical_in {
if thick_wire_spans_single_cell(output_position, input_position) {
return BezPath::new();
}
let trim = 3. * (input_position.y - output_position.y).signum();
(output_position + DVec2::new(0., trim), input_position - DVec2::new(0., trim))
} else {
(output_position, input_position)
};
match graph_wire_style {
GraphWireStyle::Direct => {
let horizontal_gap = (output_position.x - input_position.x).abs();
@@ -101,6 +120,31 @@ pub fn build_vector_wire(output_position: DVec2, input_position: DVec2, vertical
}
}
fn thick_wire_spans_single_cell(output_position: DVec2, input_position: DVec2) -> bool {
let grid_spacing = 24.;
(output_position.x - input_position.x).abs() < 1. && (output_position.y - input_position.y).abs() <= grid_spacing
}
/// The center line that cleaves a thick layer-stack wire. Its ends reach past the wire (1.5px toward the output
/// connector and 2px toward the input) so the color runs through the full cleaved connector slots. Empty for other wires.
pub fn build_thick_wire_center_line(output_position: DVec2, input_position: DVec2, vertical_out: bool, vertical_in: bool) -> BezPath {
if !(vertical_out && vertical_in) || thick_wire_spans_single_cell(output_position, input_position) {
return BezPath::new();
}
// The 8px wire trims 3px at each end; the center line trims less so it reaches further into the cleaved slots
let sign = (input_position.y - output_position.y).signum();
let output_trim = 1.5;
let input_trim = 1.;
let start = output_position + DVec2::new(0., output_trim * sign);
let end = input_position - DVec2::new(0., input_trim * sign);
let mut center_line = BezPath::new();
center_line.move_to(dvec2_to_point(start));
center_line.line_to(dvec2_to_point(end));
center_line
}
fn straight_wire_path(output_position: DVec2, input_position: DVec2, vertical_out: bool, vertical_in: bool) -> Vec<IVec2> {
let grid_spacing = 24;
let line_width = 2;

View File

@@ -70,10 +70,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
// ================================
// brush
// ================================
NodeReplacement {
node: graphene_std::brush::brush::blit::IDENTIFIER,
aliases: &["graphene_brush::BlitNode", "graphene_std::brush::BlitNode", "graphene_brush::brush::BlitNode"],
},
NodeReplacement {
node: graphene_std::brush::brush::brush::IDENTIFIER,
aliases: &["graphene_brush::BrushNode", "graphene_std::brush::BrushNode", "graphene_brush::brush::BrushNode"],
@@ -172,8 +168,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::graphic::remove_at_index::IDENTIFIER,
aliases: &["graphic_nodes::graphic::OmitElementNode"],
},
// The legacy layer extend no longer exists as a node; the aliases still land on its identifier so the
// subgraph rebuild below recognizes and replaces the networks that carried it.
NodeReplacement {
node: graphene_std::graphic::legacy_layer_extend::IDENTIFIER,
node: ProtoNodeIdentifier::new("graphic_nodes::graphic::LegacyLayerExtendNode"),
aliases: &[
"graphene_core::graphic_element::LayerNode",
"graphene_core::graphic_types::LayerNode",
@@ -739,8 +737,12 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
// vector
// ================================
NodeReplacement {
node: graphene_std::vector::apply_transform::IDENTIFIER,
aliases: &["graphene_core::vector::ApplyTransformNode", "graphene_core::vector::vector_modification::ApplyTransformNode"],
node: graphene_std::vector::bake_transform::IDENTIFIER,
aliases: &[
"graphene_core::vector::ApplyTransformNode",
"graphene_core::vector::vector_modification::ApplyTransformNode",
"vector_nodes::vector_modification_nodes::ApplyTransformNode",
],
},
NodeReplacement {
node: graphene_std::vector::area::IDENTIFIER,
@@ -846,9 +848,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::context::read_index::IDENTIFIER,
aliases: &["graphene_core::vector::InstanceIndexNode", "core_types::vector::InstanceIndexNode"],
},
// The string map folded into the general Map, and its reader into the vararg readers.
NodeReplacement {
node: graphene_std::graphic::map::IDENTIFIER,
aliases: &["graphene_core::vector::InstanceMapNode"],
aliases: &["graphene_core::vector::InstanceMapNode", "text_nodes::MapStringNode"],
},
NodeReplacement {
node: graphene_std::context::read_position::IDENTIFIER,
@@ -858,6 +861,10 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
node: graphene_std::context::read_vector::IDENTIFIER,
aliases: &["graphene_core::vector::InstanceVectorNode"],
},
NodeReplacement {
node: graphene_std::context::read_string::IDENTIFIER,
aliases: &["text_nodes::ReadStringNode"],
},
NodeReplacement {
node: graphene_std::repeat::repeat::IDENTIFIER,
aliases: &["graphene_core::vector::InstanceRepeatNode", "core_types::vector::InstanceRepeatNode"],
@@ -1289,9 +1296,9 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
migrate_node(node_id, node, network_path, document, reset_node_definitions_on_open);
}
// The old geometry-producing "Text" node was split into the current "Text" (`String[]`) -> "Text to Vector" pair, which reuses the same
// proto identifier. Runs after `migrate_node` normalizes old text nodes to the legacy 13-input layout, distinguished from the current
// 12-input node by the trailing `separate_glyphs` input (index 12): forward inputs 0..=11 onto the new node and move it onto `text_to_vector`.
// The old geometry-producing "Text" node was split into the current "Text" (`String[]`) -> converter pair, which reuses the same proto
// identifier. Runs after `migrate_node` normalizes old text nodes to the legacy 13-input layout, distinguished from the current 12-input
// node by the trailing `separate_glyphs` input (index 12): forward inputs 0..=11 onto the new node and splice the matching converter after it.
let old_text_nodes: Vec<(NodeId, Vec<NodeId>)> = document
.network_interface
.document_network()
@@ -1325,7 +1332,8 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
document.network_interface.set_input(&InputConnector::node(*node_id, new_index), input.clone(), network_path);
}
}
let separate_glyphs = old_inputs.get(12).cloned();
// A `true` toggle at index 12 chose per-glyph geometry, which is now the dedicated "Text to Vector Glyphs" node
let separate_glyphs = matches!(old_inputs.get(12).and_then(|input| input.as_value()), Some(TaggedValue::Bool(true)));
// Collect the inputs reading the old text node's output before any rewiring so the new node can be spliced onto those wires.
let downstream_consumers: Vec<InputConnector> = document
@@ -1337,40 +1345,35 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_
let text_was_in_chain = text_nodes_in_chain.contains(node_id);
// Insert the `text_to_vector` node that converts the `text` `String[]` output back into vector geometry.
let Some(text_to_vector_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::text::text_to_vector::IDENTIFIER)) else {
// Insert the converter that turns the `text` `String[]` output back into vector geometry: "Text to Vector Glyphs" for the per-glyph case, otherwise "Text to Vector".
let converter_identifier = if separate_glyphs {
graphene_std::text::text_to_vector_glyphs::IDENTIFIER
} else {
graphene_std::text::text_to_vector::IDENTIFIER
};
let Some(converter_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(converter_identifier)) else {
continue;
};
let text_to_vector_id = NodeId::new();
document
.network_interface
.insert_node(text_to_vector_id, text_to_vector_definition.default_node_template(), network_path);
let converter_id = NodeId::new();
document.network_interface.insert_node(converter_id, converter_definition.default_node_template(), network_path);
// Splice `text_to_vector` onto the wire(s) leaving `text` (`insert_node_between` is the pure wire-splice the editor uses for
// dropping a node on a wire), then carry the old `separate_glyphs` value onto its second input.
// Splice the converter onto the wire(s) leaving `text` (`insert_node_between` is the pure wire-splice the editor uses for dropping a node on a wire).
if let Some((first_consumer, remaining_consumers)) = downstream_consumers.split_first() {
document.network_interface.insert_node_between(&text_to_vector_id, first_consumer, 0, network_path);
document.network_interface.insert_node_between(&converter_id, first_consumer, 0, network_path);
for consumer in remaining_consumers {
document.network_interface.set_input(consumer, NodeInput::node(text_to_vector_id, 0), network_path);
document.network_interface.set_input(consumer, NodeInput::node(converter_id, 0), network_path);
}
} else {
document
.network_interface
.set_input(&InputConnector::node(text_to_vector_id, 0), NodeInput::node(*node_id, 0), network_path);
}
if let Some(separate_glyphs) = separate_glyphs {
document.network_interface.set_input(&InputConnector::node(text_to_vector_id, 1), separate_glyphs, network_path);
document.network_interface.set_input(&InputConnector::node(converter_id, 0), NodeInput::node(*node_id, 0), network_path);
}
// If `text` was in a layer chain, re-chain `text_to_vector` and its upstream so both lay out by distance from the layer (the splice
// broke the chain, like `move_node_to_chain_start`). Otherwise `text` is absolute, so place `text_to_vector` beside it instead of
// If `text` was in a layer chain, re-chain the converter and its upstream so both lay out by distance from the layer (the splice
// broke the chain, like `move_node_to_chain_start`). Otherwise `text` is absolute, so place the converter beside it instead of
// leaving it at the origin.
if text_was_in_chain {
document.network_interface.force_set_upstream_to_chain(&text_to_vector_id, network_path);
document.network_interface.force_set_upstream_to_chain(&converter_id, network_path);
} else if let Some(text_position) = document.network_interface.position(node_id, network_path) {
document
.network_interface
.shift_absolute_node_position(&text_to_vector_id, text_position + IVec2::new(7, 0), network_path);
document.network_interface.shift_absolute_node_position(&converter_id, text_position + IVec2::new(7, 0), network_path);
}
}
}
@@ -1639,8 +1642,8 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
inputs_count = 5;
}
// Upgrade the legacy 4-input Fill node (content, fill: Fill, _backup_color, _backup_gradient: Gradient) to the
// value-model 7-input shape (content, fill: generic paint list, _backup_color, _backup_gradient, _gradient_type, _spread_method, _transform).
// Upgrade the legacy 4-input Fill node (content, fill: Fill, _backup_color, _backup_gradient: Gradient) to the value-model
// 8-input shape (content, fill: generic paint list, _backup_color, _backup_gradient, _gradient_type, _spread_method, _has_transform, _transform).
if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector_nodes::fill::IDENTIFIER) && inputs_count == 4 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
@@ -2192,6 +2195,13 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path);
}
// A brush node saved before `Item<Raster<CPU>>` had a default stored its unconnected background as the invalid `()`,
// which fails type resolution against the raster primary; adopt the definition's empty-raster default instead.
if reference == DefinitionIdentifier::ProtoNode(graphene_std::brush::brush::brush::IDENTIFIER) && matches!(node.inputs.first().and_then(|input| input.as_value()), Some(TaggedValue::None)) {
let default_background = resolve_document_node_type(&reference)?.node_template.document_node.inputs.first()?.clone();
document.network_interface.set_input(&InputConnector::node(*node_id, 0), default_background, network_path);
}
if reference == DefinitionIdentifier::ProtoNode(ProtoNodeIdentifier::new("graphene_core::vector::RemoveHandlesNode")) {
let mut node_template = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::vector::auto_tangents::IDENTIFIER))?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
@@ -2408,7 +2418,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
// 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).
// We insert List Length → 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();
@@ -2463,10 +2473,10 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.insert_node(divide_id, divide_template, network_path);
document.network_interface.shift_absolute_node_position(&divide_id, morph_position + IVec2::new(-7, 1), network_path);
// Wire: content source → Count Elements input 0
// Wire: content source → List Length input 0
document.network_interface.set_input(&InputConnector::node(list_length_id, 0), old_inputs[0].clone(), network_path);
// Wire: Count Elements output → Subtract input 0 (minuend)
// Wire: List Length output → Subtract input 0 (minuend)
document
.network_interface
.set_input(&InputConnector::node(subtract_id, 0), NodeInput::node(list_length_id, 0), network_path);
@@ -2677,6 +2687,34 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
}
}
// A value input stored as a List-form TypeDefault adopts the definition's current default when the connector's declared default has since changed (e.g. the connector was ranked down to Item).
// The red-slash no-paint choice shares that stored form but is a deliberate value, not a stale disconnect default, so it is exempt.
if let Some(definition) = resolve_document_node_type(&reference) {
let definition_inputs = definition.node_template.document_node.inputs.clone();
for (index, definition_input) in definition_inputs.iter().enumerate() {
if !matches!(definition_input, NodeInput::Value { .. }) {
continue;
}
let stale_list_default = document
.network_interface
.input_from_connector(&InputConnector::node(*node_id, index), network_path)
.is_some_and(|stored_input| match stored_input {
NodeInput::Value { tagged_value, .. } => match &**tagged_value {
TaggedValue::TypeDefault(stored_type) if stored_type.name.contains("list::List<") && !tagged_value.is_no_paint() => {
!matches!(definition_input, NodeInput::Value { tagged_value, .. } if matches!(&**tagged_value, TaggedValue::TypeDefault(definition_type) if definition_type == stored_type))
}
_ => false,
},
_ => false,
});
if stale_list_default {
document.network_interface.set_input(&InputConnector::node(*node_id, index), definition_input.clone(), network_path);
}
}
}
// ==================================
// PUT ALL MIGRATIONS ABOVE THIS LINE
// ==================================

View File

@@ -692,7 +692,7 @@ pub struct SelectedStrokeState {
}
/// Reads the fill state across all selected non-artboard layers, including whether their enabled states or colors differ.
/// "Enabled" tracks node attachment: a layer counts as enabled whenever a Fill node is attached, even when that fill's value is [`FillChoice::None`].
/// "Enabled" tracks node attachment: a layer counts as enabled whenever a Fill node is attached, even when that fill's value is the no-paint choice.
/// Unticked means there is no Fill node. Returns `None` only when no layer is selected.
pub fn selected_fill_state(document: &DocumentMessageHandler) -> Option<SelectedFillState> {
let selected_nodes = document.network_interface.selected_nodes();

View File

@@ -421,7 +421,7 @@ impl ShapeState {
(point.as_handle().is_some() && self.ignore_handles) || (point.as_anchor().is_some() && self.ignore_anchors)
}
/// Applies a dummy vector modification to the layer. In the case where a group containing some vector data is selected, this triggers the creation of a «Flatten Path» node.
/// Applies a dummy vector modification to the layer. In the case where a group containing some vector data is selected, this triggers the creation of a Flatten Path node.
fn add_dummy_modification_to_trigger_graph_reorganization(layer: LayerNodeIdentifier, start_point: PointId, _end_point: PointId, responses: &mut VecDeque<Message>) {
// Apply a zero-delta to one of the points to trigger reorganization
let dummy_modification = VectorModificationType::ApplyPointDelta {

View File

@@ -75,8 +75,8 @@ mod test_ellipse {
let node_graph_layer = NodeGraphLayer::new(layer, &document.network_interface);
let ellipse_node = node_graph_layer.upstream_node_id_from_protonode(ellipse::IDENTIFIER)?;
Some(ResolvedEllipse {
radius_x: instrumented.grab_protonode_input::<ellipse::RadiusXInput>(&vec![ellipse_node], &editor.runtime).unwrap(),
radius_y: instrumented.grab_protonode_input::<ellipse::RadiusYInput>(&vec![ellipse_node], &editor.runtime).unwrap(),
radius_x: instrumented.grab_ranked_input::<ellipse::RadiusXInput>(&vec![ellipse_node], &editor.runtime).unwrap(),
radius_y: instrumented.grab_ranked_input::<ellipse::RadiusYInput>(&vec![ellipse_node], &editor.runtime).unwrap(),
transform: document.metadata().transform_to_document(layer),
})
})

View File

@@ -568,7 +568,7 @@ pub fn make_path_editable_is_allowed(network_interface: &mut NodeNetworkInterfac
}
for _ in selected_layers {}
// Must be a layer of type List<Vector>
// Must be a vector layer, at either rank
let node_id = NodeGraphLayer::new(first_layer, network_interface).horizontal_layer_flow().nth(1)?;
let output_type = network_interface.output_type(&OutputConnector::node(node_id, 0), &[]);

View File

@@ -465,7 +465,6 @@ impl NodeGraphExecutor {
resolved_types: incomplete_delta,
node_graph_errors,
});
responses.add(NodeGraphMessage::SendGraph);
return Err(format!("Node graph evaluation failed:\n{e}"));
}
@@ -476,7 +475,6 @@ impl NodeGraphExecutor {
resolved_types: type_delta,
node_graph_errors,
});
responses.add(NodeGraphMessage::SendGraph);
}
NodeGraphUpdate::EyedropperPreview(raster) => {
let (data, width, height) = raster.to_flat_u8();
@@ -836,8 +834,8 @@ impl NodeGraphExecutor {
}
// TODO: Eventually remove this document upgrade code
/// Whether the fill node's transform input is still the unset `OptionalDAffine2(None)` placeholder that the migration leaves
/// behind, meaning its gradient placement has not yet been baked (or set by the user), so a measured bake may safely be written.
/// Whether the fill node's `_has_transform` is still `false`, meaning its gradient placement has not yet been baked
/// (or set by the user), so a measured bake may safely be written.
fn fill_transform_unbaked(document: &DocumentMessageHandler, network_path: &[NodeId], fill_node_id: NodeId) -> bool {
let Some(network) = document.network_interface.document_network().nested_network(network_path) else {
return false;
@@ -946,11 +944,17 @@ mod test {
let mut monitor_node_ids = Vec::with_capacity(node.inputs.len());
for input in &mut node.inputs {
let node_id = NodeId::new();
let old_input = std::mem::replace(input, NodeInput::node(node_id, 0));
monitor_nodes.push((old_input, node_id));
path.push(node_id);
monitor_node_ids.push(path.clone());
path.pop();
// A None value is a unit wire with nothing to record and no Monitor row, so its slot stays a dead path that introspects as absent
if matches!(input, NodeInput::Value { tagged_value, .. } if matches!(&**tagged_value, graph_craft::document::value::TaggedValue::None)) {
continue;
}
let old_input = std::mem::replace(input, NodeInput::node(node_id, 0));
monitor_nodes.push((old_input, node_id));
}
if let DocumentNodeImplementation::ProtoNode(identifier) = &mut node.implementation {
path.push(*id);
@@ -982,13 +986,18 @@ mod test {
where
Input::Result: Send + Sync + Clone + 'static,
{
let element = dynamic.downcast_ref::<Input::Result>().cloned();
let element = Self::downcast_record::<Input::Result>(dynamic);
if element.is_none() {
warn!("cannot downcast type for introspection");
}
element
}
/// Our monitor introspects as the recorded value itself, not as an `IORecord` wrapper.
fn downcast_record<Output: Send + Sync + Clone + 'static>(dynamic: Arc<dyn std::any::Any + Send + Sync>) -> Option<Output> {
dynamic.downcast_ref::<Output>().cloned()
}
/// Grab all of the values of a LEVELED input, which introspects as its
/// whole legacy list rather than as one element. `T` is the introspected
/// element type, which differs from the declared one where a conversion
@@ -1003,6 +1012,18 @@ mod test {
.filter_map(|dynamic| dynamic.downcast_ref::<List<T>>().cloned())
}
/// Like [`Self::grab_all_input_level`], but downcasting each record to `Output` instead of to the marker's `Result`.
/// Useful when a stored value's recorded form differs from the declared row types the marker's generic accepts.
pub fn grab_all_input_as<'a, Input: NodeInputDecleration + 'a, Output: Send + Sync + Clone + 'static>(&'a self, runtime: &'a NodeRuntime) -> impl Iterator<Item = Output> + 'a {
self.protonodes_by_name
.get(&Input::identifier())
.map_or([].as_slice(), |x| x.as_slice())
.iter()
.filter_map(|inputs| inputs.get(Input::INDEX))
.filter_map(|input_monitor_node| runtime.executor.introspect(input_monitor_node).ok())
.filter_map(Instrumented::downcast_record::<Output>)
}
pub fn grab_protonode_input<Input: NodeInputDecleration>(&self, path: &Vec<NodeId>, runtime: &NodeRuntime) -> Option<Input::Result>
where
Input::Result: Send + Sync + Clone + 'static,
@@ -1014,6 +1035,14 @@ mod test {
Self::downcast::<Input>(dynamic)
}
/// Grabs a ranked input's recorded value as its bare element; our monitor serves a rank-0 input as the element itself.
pub fn grab_ranked_input<Input: NodeInputDecleration>(&self, path: &Vec<NodeId>, runtime: &NodeRuntime) -> Option<Input::Result>
where
Input::Result: Send + Sync + Clone + 'static,
{
self.grab_protonode_input::<Input>(path, runtime)
}
pub fn grab_input_from_layer<Input: NodeInputDecleration>(&self, layer: LayerNodeIdentifier, network_interface: &NodeNetworkInterface, runtime: &NodeRuntime) -> Option<Input::Result>
where
Input::Result: Send + Sync + Clone + 'static,