From c77359bedfeac98c72ea858bdb3fcef194f15b5d Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Sun, 26 Jul 2026 12:15:14 -0700 Subject: [PATCH] Add a Connect Cells option to the 'Grid' node (#4374) Add a Connect Cells option to the Grid node --- .../document/node_graph/node_properties.rs | 4 +- .../messages/portfolio/document_migration.rs | 37 +++-- .../nodes/vector/src/generator_nodes.rs | 146 ++++++++++-------- 3 files changed, 101 insertions(+), 86 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 46a7321819..7fbd2b3525 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 ae03d2eccf..80009ad7e3 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2315,33 +2315,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; + // 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 let Some(NodeInput::Value { tagged_value, exposed: _ }) = index_3_value - && matches!(*tagged_value, TaggedValue::DVec2(_)) - { - // Move index 3 to the end + 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 46663a9aee..a9c5a8e369 100644 --- a/node-graph/nodes/vector/src/generator_nodes.rs +++ b/node-graph/nodes/vector/src/generator_nodes.rs @@ -306,84 +306,76 @@ fn grid( #[default(10)] columns: u32, #[default(10)] rows: u32, #[default(30., 30.)] angles: DVec2, + #[default(true)] connect_cells: bool, ) -> Vector { let (x_spacing, y_spacing) = spacing.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 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)); + 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 above (vertical connection) - push_segment(current_index.checked_sub(columns as usize)); + // 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); } - } - } - 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(); + // Connect to the point to the left (horizontal connection). + push_segment((x > 0).then(|| current_index - 1)); - let a_angles_eaten = x.div_ceil(2) as f64; - let b_angles_eaten = (x / 2) as f64; + // Connect to the point directly above (vertical connection). + push_segment(current_index.checked_sub(columns as usize)); - let offset_y_fraction = b_angles_eaten * tan_b - a_angles_eaten * tan_a; + // 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)); - 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)); - } - } + // Connect to the point diagonally up-left. + push_segment(current_index.checked_sub(columns as usize + 1)); } } } @@ -397,11 +389,11 @@ mod tests { #[test] fn isometric_grid_test() { // Doesn't crash with weird angles - grid(&(), (), GridType::Isometric, 0., 5, 5, (0., 0.).into()); - grid(&(), (), GridType::Isometric, 90., 5, 5, (90., 90.).into()); + grid(&(), (), GridType::Isometric, 0., 5, 5, (0., 0.).into(), true); + grid(&(), (), GridType::Isometric, 90., 5, 5, (90., 90.).into(), true); // Works properly - let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into()); + let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (30., 30.).into(), true); assert_eq!(grid.point_domain.ids().len(), 5 * 5); assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9); for (_, bezier, _, _) in grid.segment_bezier_iter() { @@ -416,7 +408,7 @@ mod tests { #[test] fn skew_isometric_grid_test() { - let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into()); + let grid = grid(&(), (), GridType::Isometric, 10., 5, 5, (40., 30.).into(), true); assert_eq!(grid.point_domain.ids().len(), 5 * 5); assert_eq!(grid.segment_bezier_iter().count(), 4 * 5 + 4 * 9); for (_, bezier, _, _) in grid.segment_bezier_iter() { @@ -427,6 +419,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(&(), (), GridType::Rectangular, 10., 3_u32, 3_u32, (30., 30.).into(), false); + let vector = grid; + 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(&(), (), "https://graphite.art".to_string(), false, 1., QRCodeErrorCorrectionLevel::Low, true);