Implement dynamic table attributes to generalize the graphic-specific Table type (#4050)

* Feature-gate serde derives behind cfg_attr in all runtime node graph type crates

* Refactor Table to move its hard-coded fields into an attributes field

* Encapsulate TableRow/TableRowRef/TableRowMut attribute fields behind accessor methods

* Remove TaggedValue::GraphicUnused

* Refactor Table<T> to use dynamic attributes instead fixed names

* Fix code review soundness concerns

* Add todo work

* Replace row-oriented Table<T> API with column-oriented access

* Fix attribute propagation bugs

---------
This commit is contained in:
Keavon Chambers
2026-04-28 02:10:24 -07:00
parent 324b9e664c
commit 76938eb69a
75 changed files with 2330 additions and 1349 deletions

View File

@@ -4,9 +4,8 @@ use crate::messages::portfolio::document::data_panel::DataPanelMessage;
use crate::messages::portfolio::document::utility_types::network_interface::NodeNetworkInterface;
use crate::messages::prelude::*;
use crate::messages::tool::tool_messages::tool_prelude::*;
use glam::{Affine2, Vec2};
use glam::{Affine2, DAffine2, Vec2};
use graph_craft::document::NodeId;
use graphene_std::Color;
use graphene_std::Context;
use graphene_std::gradient::GradientStops;
use graphene_std::memo::IORecord;
@@ -14,6 +13,7 @@ use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::table::Table;
use graphene_std::vector::Vector;
use graphene_std::vector::style::{Fill, FillChoice};
use graphene_std::{AlphaBlending, Color};
use graphene_std::{Artboard, Graphic};
use std::any::Any;
use std::sync::Arc;
@@ -247,9 +247,9 @@ impl<T: TableRowLayout> TableRowLayout for Table<T> {
}
fn element_page(&self, data: &mut LayoutData) -> Vec<LayoutGroup> {
if let Some(index) = data.desired_path.get(data.current_depth).copied() {
if let Some(row) = self.get(index) {
if let Some(element) = self.element(index) {
data.current_depth += 1;
let result = row.element.layout_with_breadcrumb(data);
let result = element.layout_with_breadcrumb(data);
data.current_depth -= 1;
return result;
} else {
@@ -258,23 +258,37 @@ impl<T: TableRowLayout> TableRowLayout for Table<T> {
}
}
let mut rows = self
.iter()
.enumerate()
.map(|(index, row)| {
vec![
TextLabel::new(format!("{index}")).narrow(true).widget_instance(),
row.element.element_widget(index),
TextLabel::new(format_transform_matrix(row.transform)).narrow(true).widget_instance(),
TextLabel::new(format!("{}", row.alpha_blending)).narrow(true).widget_instance(),
TextLabel::new(row.source_node_id.map_or_else(|| "-".to_string(), |id| format!("{}", id.0)))
.narrow(true)
.widget_instance(),
]
let attribute_keys: Vec<String> = self.attribute_keys().map(str::to_string).collect();
let mut rows = (0..self.len())
.map(|index| {
let element = self.element(index).unwrap();
let mut cells = vec![TextLabel::new(format!("{index}")).narrow(true).widget_instance(), element.element_widget(index)];
for key in &attribute_keys {
let value = self
.attribute_display_value(key, index, |ty| {
if let Some(&value) = ty.downcast_ref::<DAffine2>() {
Some(format_transform_matrix(value))
} else if let Some(&value) = ty.downcast_ref::<DVec2>() {
Some(format_dvec2(value))
} else if let Some(&value) = ty.downcast_ref::<AlphaBlending>() {
Some(format_alpha_blending(value))
} else if let Some(&value) = ty.downcast_ref::<Option<NodeId>>() {
Some(value.map_or_else(|| "-".to_string(), |id| id.to_string()))
} else {
None
}
})
.unwrap_or_else(|| "-".to_string());
cells.push(TextLabel::new(value).narrow(true).widget_instance());
}
cells
})
.collect::<Vec<_>>();
rows.insert(0, column_headings(&["", "element", "transform", "alpha_blending", "source_node_id"]));
let mut column_names = vec!["", "element"];
column_names.extend(attribute_keys.iter().map(|s| s.as_str()));
rows.insert(0, column_headings(&column_names));
vec![LayoutGroup::table(rows, false)]
}
@@ -430,7 +444,7 @@ impl TableRowLayout for Vector {
]);
table_rows.push(vec![
TextLabel::new("Stroke Transform").narrow(true).widget_instance(),
TextLabel::new(format_transform_matrix(&stroke.transform)).narrow(true).widget_instance(),
TextLabel::new(format_transform_matrix(stroke.transform)).narrow(true).widget_instance(),
]);
table_rows.push(vec![
TextLabel::new("Stroke Paint Order").narrow(true).widget_instance(),
@@ -695,7 +709,7 @@ impl TableRowLayout for DAffine2 {
"Transform".to_string()
}
fn element_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
let widgets = vec![TextLabel::new(format_transform_matrix(self)).widget_instance()];
let widgets = vec![TextLabel::new(format_transform_matrix(*self)).widget_instance()];
vec![LayoutGroup::row(widgets)]
}
}
@@ -709,12 +723,12 @@ impl TableRowLayout for Affine2 {
}
fn element_page(&self, _data: &mut LayoutData) -> Vec<LayoutGroup> {
let matrix = DAffine2::from_cols_array(&self.to_cols_array().map(|x| x as f64));
let widgets = vec![TextLabel::new(format_transform_matrix(&matrix)).widget_instance()];
let widgets = vec![TextLabel::new(format_transform_matrix(matrix)).widget_instance()];
vec![LayoutGroup::row(widgets)]
}
}
fn format_transform_matrix(transform: &DAffine2) -> String {
fn format_transform_matrix(transform: DAffine2) -> String {
let (scale, angle, translation) = if transform.matrix2.determinant().abs() <= f64::EPSILON {
let [col_0, col_1] = transform.matrix2.to_cols_array_2d().map(|[x, y]| DVec2::new(x, y));
@@ -748,3 +762,14 @@ fn format_dvec2(value: DVec2) -> String {
let round = |x: f64| (x * 1e3).round() / 1e3;
format!("({} px, {} px)", round(value.x), round(value.y))
}
fn format_alpha_blending(value: AlphaBlending) -> String {
let round = |x: f32| (x * 1e3).round() / 1e3;
format!(
"Blend Mode: {} — Opacity: {}% — Fill: {}% — Clip: {}",
value.blend_mode,
round(value.opacity * 100.),
round(value.fill * 100.),
if value.clip { "Yes" } else { "No" }
)
}

View File

@@ -713,10 +713,10 @@ fn set_import_child_positions(
let child_pos = IVec2::new(child_x, current_y);
if i == 0 {
// Top of stack set to `Absolute` position
// Top of stack: set to `Absolute` position
network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Absolute(child_pos), &[]);
} else {
// Below top set `Stack` with `y_offset` based on previous sibling's subtree extent
// Below top: set `Stack` with `y_offset` based on previous sibling's subtree extent
let prev_sibling_svg_index = n - i;
let y_offset = child_extents_svg_order[prev_sibling_svg_index] + STACK_VERTICAL_GAP as u32;
network_interface.set_layer_position_for_import(&child_layer.to_node(), LayerPosition::Stack(y_offset), &[]);

View File

@@ -1178,8 +1178,8 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
match &**tagged_value {
TaggedValue::Color(color_table) => widgets.push(
color_button
.value(match color_table.iter().next() {
Some(color) => FillChoice::Solid(*color.element),
.value(match color_table.element(0) {
Some(color) => FillChoice::Solid(*color),
None => FillChoice::None,
})
.on_update(update_value(
@@ -1192,8 +1192,8 @@ pub fn color_widget(parameter_widgets_info: ParameterWidgetsInfo, color_button:
),
TaggedValue::GradientTable(gradient_table) => widgets.push(
color_button
.value(match gradient_table.iter().next() {
Some(row) => FillChoice::Gradient(row.element.clone()),
.value(match gradient_table.element(0) {
Some(gradient) => FillChoice::Gradient(gradient.clone()),
None => FillChoice::Gradient(GradientStops::default()),
})
.on_update(update_value(

View File

@@ -1166,16 +1166,18 @@ impl OverlayContextInternal {
fn render_text_paths(&mut self, text_table: &Table<Vector>, font_color: &str, base_transform: kurbo::Affine) {
let color = Self::parse_color(font_color);
for row in text_table.iter() {
for index in 0..text_table.len() {
// Use the existing bezier_to_path infrastructure to convert Vector to BezPath
let mut path = BezPath::new();
let mut last_point = None;
let transform: DAffine2 = text_table.attribute_cloned_or_default("transform", index);
for (_, bezier, start_id, end_id) in row.element.segment_iter() {
let Some(element) = text_table.element(index) else { continue };
for (_, bezier, start_id, end_id) in element.segment_iter() {
let move_to = last_point != Some(start_id);
last_point = Some(end_id);
self.bezier_to_path(bezier, *row.transform, move_to, &mut path);
self.bezier_to_path(bezier, transform, move_to, &mut path);
}
// Render the path

View File

@@ -104,7 +104,7 @@ impl DocumentMetadata {
.any(|upstream| Some(upstream) == source)
{
use_local = false;
info!("Local transform is invalid — using the identity for the local transform instead")
info!("Local transform is invalid. Using the identity for the local transform instead.");
}
let local_transform = use_local.then(|| self.local_transforms.get(&layer.to_node()).copied()).flatten().unwrap_or_default();

View File

@@ -5521,11 +5521,11 @@ impl NodeNetworkInterface {
match post_node_input {
NodeInput::Value { .. } | NodeInput::Scope(_) | NodeInput::Inline(_) | NodeInput::Reflection(_) => {
// First child in the stack wire layer output to the post_node input
// First child in the stack: wire layer output to the post_node input
self.set_input_for_import(&post_node, layer_output, network_path);
}
NodeInput::Node { .. } => {
// Subsequent child insert layer between post_node and its current upstream:
// Subsequent child: insert layer between post_node and its current upstream...
// 1. Disconnect old upstream from post_node, wire layer output to post_node
self.set_input_for_import(&post_node, layer_output, network_path);
// 2. Wire old upstream into layer's primary (stack) input

View File

@@ -1962,7 +1962,7 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
&& let TaggedValue::Vector(vector_table) = &**tagged_value
&& !vector_table.is_empty()
{
let vector = vector_table.iter().next()?.element;
let vector = vector_table.element(0)?;
let modification = Box::new(graphene_std::vector::VectorModification::create_from_vector(vector));
// Reset input 0 to the default exposed state
@@ -1981,9 +1981,9 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster_nodes::std_nodes::image::IDENTIFIER)
&& let Some(NodeInput::Value { tagged_value, .. }) = node.inputs.get(1)
&& let TaggedValue::Raster(raster_table) = &**tagged_value
&& let Some(row) = raster_table.iter().next()
&& let Some(element) = raster_table.element(0)
{
let image = row.element.data().clone();
let image = element.data().clone();
document
.network_interface

View File

@@ -646,10 +646,12 @@ mod test_artboard {
/// Check if all of the artboards exist in any ordering
async fn has_artboards(editor: &mut EditorTestUtils, mut expected: Vec<ArtboardLayoutDocument>) {
let artboards = get_artboards(editor)
.await
.iter()
.map(|row| ArtboardLayoutDocument::new(row.element.location, row.element.dimensions))
let artboards = get_artboards(editor).await;
let artboards = (0..artboards.len())
.map(|index| {
let element = artboards.element(index).unwrap();
ArtboardLayoutDocument::new(element.location, element.dimensions)
})
.collect::<Vec<_>>();
assert_eq!(artboards.len(), expected.len(), "incorrect len: actual {:?}, expected {:?}", artboards, expected);

View File

@@ -15,7 +15,7 @@ use graphene_std::ops::Convert;
use graphene_std::platform_application_io::canvas_utils::{Canvas, CanvasSurface, CanvasSurfaceHandle};
use graphene_std::raster_types::Raster;
use graphene_std::renderer::{Render, RenderParams, RenderSvgSegmentList, SvgRender, SvgSegment};
use graphene_std::table::{Table, TableRow};
use graphene_std::table::Table;
use graphene_std::text::FontCache;
use graphene_std::transform::RenderQuality;
use graphene_std::vector::Vector;
@@ -441,9 +441,7 @@ impl NodeRuntime {
// Vector table: vector modifications
else if let Some(io) = introspected_data.downcast_ref::<IORecord<Context, Table<Vector>>>() {
// Insert the vector modify
let default = TableRow::default();
self.vector_modify
.insert(parent_network_node_id, io.output.iter().next().unwrap_or_else(|| default.as_ref()).element.clone());
self.vector_modify.insert(parent_network_node_id, io.output.element(0).cloned().unwrap_or_default());
}
// Other
else {