From 2f7ab898cafe834b47ade6ed9d04a22bf96ab2e0 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 28 Jun 2026 00:42:05 -0700 Subject: [PATCH] Add a Connect Cells option to the Grid node --- .../document/node_graph/node_properties.rs | 4 +- .../messages/portfolio/document_migration.rs | 39 +++-- .../nodes/vector/src/generator_nodes.rs | 158 ++++++++++-------- 3 files changed, 108 insertions(+), 93 deletions(-) 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 c01f6cf3e0..df8872fcd2 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1901,7 +1901,9 @@ pub(crate) fn grid_properties(node_id: NodeId, context: &mut NodePropertiesConte let columns = number_widget(ParameterWidgetsInfo::new(node_id, ColumnsInput::INDEX, true, context), NumberInput::default().min(1.)); let rows = number_widget(ParameterWidgetsInfo::new(node_id, RowsInput::INDEX, true, context), NumberInput::default().min(1.)); - widgets.extend([LayoutGroup::row(columns), LayoutGroup::row(rows)]); + let connect_cells = bool_widget(ParameterWidgetsInfo::new(node_id, ConnectCellsInput::INDEX, true, context), CheckboxInput::default()); + + widgets.extend([LayoutGroup::row(columns), LayoutGroup::row(rows), LayoutGroup::row(connect_cells)]); widgets } diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index ed64588bef..4de2a44b0a 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2268,33 +2268,36 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], } } - // Make the "Grid" node, if its input of index 3 is a DVec2 for "angles" instead of a u32 for the "columns" input that now succeeds "angles", move the angle to index 5 (after "columns" and "rows") + // Upgrade the "Grid" node from its six-input layout to the current seven-input layout (which adds a trailing + // "connect_cells" toggle, defaulting to the connected mesh that older grids produced). Legacy documents also placed + // "angles" at index 3 instead of index 5 (after "columns" and "rows"), so we reorder those. Either way, "connect_cells" + // is left at its default from the new node template. if reference == DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::grid::IDENTIFIER) && inputs_count == 6 { - let node_definition = resolve_document_node_type(&reference)?; - let mut new_node_template = node_definition.default_node_template(); - - let mut current_node_template = document.network_interface.create_node_template(node_id, network_path)?; + let mut new_node_template = resolve_document_node_type(&reference)?.default_node_template(); let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut new_node_template)?; - let index_3_value = old_inputs.get(3).cloned(); - - let mut upgraded = false; - if let Some(NodeInput::Value { tagged_value, exposed: _ }) = index_3_value - && matches!(*tagged_value, TaggedValue::DVec2(_)) - { - // Move index 3 to the end + // The two six-input layouts differ only in where the DVec2 "angles" and the u32 "columns"/"rows" sit: + // Legacy: [primary, grid_type, spacing, angles (DVec2), columns (u32), rows (u32)] + // Modern: [primary, grid_type, spacing, columns (u32), rows (u32), angles (DVec2)] + // So a DVec2 "angles" at index 3, or a u32 "rows" at index 5, marks the legacy order. Checking both slots classifies + // correctly even when one of them is a wired or imported connection rather than a literal value. + let index_3_is_angles = matches!(old_inputs.get(3), Some(NodeInput::Value { tagged_value, .. }) if matches!(**tagged_value, TaggedValue::DVec2(_))); + let index_5_is_rows = matches!(old_inputs.get(5), Some(NodeInput::Value { tagged_value, .. }) if matches!(**tagged_value, TaggedValue::U32(_))); + let legacy_angles_layout = index_3_is_angles || index_5_is_rows; + + if legacy_angles_layout { + // Old order: [primary, grid_type, spacing, angles, columns, rows]. Move "angles" from index 3 to index 5. document.network_interface.set_input(&InputConnector::node(*node_id, 0), old_inputs[0].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 1), old_inputs[1].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 2), old_inputs[2].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 3), old_inputs[4].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 4), old_inputs[5].clone(), network_path); document.network_interface.set_input(&InputConnector::node(*node_id, 5), old_inputs[3].clone(), network_path); - - upgraded = true; - } - - if !upgraded { - let _ = document.network_interface.replace_inputs(node_id, network_path, &mut current_node_template); + } else { + // Modern six-input order. Carry each input over to the same index. + for (index, input) in old_inputs.iter().take(6).enumerate() { + document.network_interface.set_input(&InputConnector::node(*node_id, index), input.clone(), network_path); + } } } diff --git a/node-graph/nodes/vector/src/generator_nodes.rs b/node-graph/nodes/vector/src/generator_nodes.rs index a19ae61183..c0fa20ba97 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -321,86 +321,78 @@ fn grid( #[default(10)] columns: Item, #[default(10)] rows: Item, #[default(30., 30.)] angles: Item, + #[default(true)] connect_cells: Item, ) -> Item { - let (grid_type, columns, rows, angles) = (grid_type.into_element(), *columns.element(), *rows.element(), *angles.element()); + let (grid_type, columns, rows, angles, connect_cells) = (grid_type.into_element(), *columns.element(), *rows.element(), *angles.element(), *connect_cells.element()); let (x_spacing, y_spacing) = spacing.element().as_dvec2().into(); let (angle_a, angle_b) = angles.into(); + // Isometric grid spacing based on the two skew angles. Unused for rectangular grids. + let tan_a = angle_a.to_radians().tan(); + let tan_b = angle_b.to_radians().tan(); + let isometric_spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing); + + // The position of the grid point at column `x`, row `y`. + let position = |x: u32, y: u32| -> DVec2 { + match grid_type { + GridType::Rectangular => DVec2::new(x_spacing * x as f64, y_spacing * y as f64), + GridType::Isometric => { + // Odd columns are offset vertically so the cells skew into the isometric shape. + let a_angles_eaten = x.div_ceil(2) as f64; + let b_angles_eaten = (x / 2) as f64; + let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a; + DVec2::new(isometric_spacing.x * x as f64, isometric_spacing.y * y as f64 + offset_y_fraction * isometric_spacing.x) + } + } + }; + + // When the cells aren't connected, each one is its own closed quadrilateral subpath. + // The vertices are ordered counter-clockwise to match the framework's fill winding. + if !connect_cells { + let mut cells = Vec::new(); + for y in 0..rows.saturating_sub(1) { + for x in 0..columns.saturating_sub(1) { + cells.push(vec![position(x, y), position(x + 1, y), position(x + 1, y + 1), position(x, y + 1)]); + } + } + let mut vector = Vector::default(); + crate::vector_nodes::replace_with_polygons(&mut vector, cells, connect_cells); + return Item::new_from_element(vector); + } + let mut vector = Vector::default(); let mut segment_id = SegmentId::ZERO; let mut point_id = PointId::ZERO; - match grid_type { - GridType::Rectangular => { - // Create rectangular grid points and connect them with line segments - for y in 0..rows { - for x in 0..columns { - // Add current point to the grid - let current_index = vector.point_domain.ids().len(); - vector.point_domain.push(point_id.next_id(), DVec2::new(x_spacing * x as f64, y_spacing * y as f64)); - - // Helper function to connect points with line segments - let mut push_segment = |to_index: Option| { - if let Some(other_index) = to_index { - vector - .segment_domain - .push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO); - } - }; - - // Connect to the point to the left (horizontal connection) - push_segment((x > 0).then(|| current_index - 1)); - - // Connect to the point above (vertical connection) - push_segment(current_index.checked_sub(columns as usize)); - } - } - } - GridType::Isometric => { - // Calculate isometric grid spacing based on angles - let tan_a = angle_a.to_radians().tan(); - let tan_b = angle_b.to_radians().tan(); - let spacing = DVec2::new(y_spacing / (tan_a + tan_b), y_spacing); - - // Create isometric grid points and connect them with line segments - for y in 0..rows { - for x in 0..columns { - // Add current point to the grid with offset for odd columns - let current_index = vector.point_domain.ids().len(); - - let a_angles_eaten = x.div_ceil(2) as f64; - let b_angles_eaten = (x / 2) as f64; - - let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a; - - let position = DVec2::new(spacing.x * x as f64, spacing.y * y as f64 + offset_y_fraction * spacing.x); - vector.point_domain.push(point_id.next_id(), position); - - // Helper function to connect points with line segments - let mut push_segment = |to_index: Option| { - if let Some(other_index) = to_index { - vector - .segment_domain - .push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO); - } - }; - - // Connect to the point to the left - push_segment((x > 0).then(|| current_index - 1)); - - // Connect to the point directly above - push_segment(current_index.checked_sub(columns as usize)); - - // Additional diagonal connections for odd columns (creates hexagonal pattern) - if x % 2 == 1 { - // Connect to the point diagonally up-right (if not at right edge) - push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns)); - - // Connect to the point diagonally up-left - push_segment(current_index.checked_sub(columns as usize + 1)); - } + for y in 0..rows { + for x in 0..columns { + // Add the current point to the grid. + let current_index = vector.point_domain.ids().len(); + vector.point_domain.push(point_id.next_id(), position(x, y)); + + // Helper function to connect points with line segments. + let mut push_segment = |to_index: Option| { + if let Some(other_index) = to_index { + vector + .segment_domain + .push(segment_id.next_id(), other_index, current_index, subpath::BezierHandles::Linear, StrokeId::ZERO); } + }; + + // Connect to the point to the left (horizontal connection). + push_segment((x > 0).then(|| current_index - 1)); + + // Connect to the point directly above (vertical connection). + push_segment(current_index.checked_sub(columns as usize)); + + // Isometric grids additionally connect odd columns diagonally, splitting each cell into triangles. + if grid_type == GridType::Isometric && x % 2 == 1 { + // Connect to the point diagonally up-right (if not at the right edge). + push_segment(current_index.checked_sub(columns as usize - 1).filter(|_| x + 1 < columns)); + + // Connect to the point diagonally up-left. + push_segment(current_index.checked_sub(columns as usize + 1)); } } } @@ -419,11 +411,11 @@ mod tests { #[test] fn isometric_grid_test() { // Doesn't crash with weird angles - grid((), (), item(GridType::Isometric), item(0.), item(5_u32), item(5_u32), item((0., 0.).into())); - grid((), (), item(GridType::Isometric), item(90.), item(5_u32), item(5_u32), item((90., 90.).into())); + grid((), (), item(GridType::Isometric), item(0.), item(5_u32), item(5_u32), item((0., 0.).into()), item(true)); + grid((), (), item(GridType::Isometric), item(90.), item(5_u32), item(5_u32), item((90., 90.).into()), item(true)); // Works properly - let grid = grid((), (), item(GridType::Isometric), item(10.), item(5_u32), item(5_u32), item((30., 30.).into())); + let grid = grid((), (), item(GridType::Isometric), item(10.), item(5_u32), item(5_u32), item((30., 30.).into()), item(true)); assert_eq!(grid.element().point_domain.ids().len(), 5 * 5); assert_eq!(grid.element().segment_bezier_iter().count(), 4 * 5 + 4 * 9); for (_, bezier, _, _) in grid.element().segment_bezier_iter() { @@ -438,7 +430,7 @@ mod tests { #[test] fn skew_isometric_grid_test() { - let grid = grid((), (), item(GridType::Isometric), item(10.), item(5_u32), item(5_u32), item((40., 30.).into())); + let grid = grid((), (), item(GridType::Isometric), item(10.), item(5_u32), item(5_u32), item((40., 30.).into()), item(true)); assert_eq!(grid.element().point_domain.ids().len(), 5 * 5); assert_eq!(grid.element().segment_bezier_iter().count(), 4 * 5 + 4 * 9); for (_, bezier, _, _) in grid.element().segment_bezier_iter() { @@ -449,6 +441,24 @@ mod tests { } } + #[test] + fn grid_disconnected_cells_test() { + // A 3x3 rectangular grid has a 2x2 arrangement of cells, each its own closed quad subpath with a fillable region. + let grid = grid((), (), item(GridType::Rectangular), item(10.), item(3_u32), item(3_u32), item((30., 30.).into()), item(false)); + let vector = grid.element(); + assert_eq!(vector.region_domain.ids().len(), 4); + assert_eq!(vector.point_domain.ids().len(), 4 * 4); + assert_eq!(vector.segment_domain.ids().len(), 4 * 4); + + // Each cell winds counter-clockwise (positive signed area), matching the shape generators. + for (group, closed) in vector.stroke_manipulator_groups() { + assert!(closed); + let anchors: Vec = group.iter().map(|g| g.anchor).collect(); + let signed_area: f64 = (0..anchors.len()).map(|i| anchors[i].perp_dot(anchors[(i + 1) % anchors.len()])).sum::() / 2.; + assert!(signed_area > 0., "grid cell should wind counter-clockwise"); + } + } + #[test] fn qr_code_test() { let qr = qr_code(