Add a Connect Cells option to the 'Grid' node (#4374)

Add a Connect Cells option to the Grid node
This commit is contained in:
Keavon Chambers
2026-07-26 12:15:14 -07:00
committed by GitHub
parent 9332c7f775
commit b2e5b6645c
3 changed files with 102 additions and 87 deletions

View File

@@ -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
}

View File

@@ -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;
// 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);
}
}
}

View File

@@ -321,86 +321,78 @@ fn grid<T: GridSpacing>(
#[default(10)] columns: Item<u32>,
#[default(10)] rows: Item<u32>,
#[default(30., 30.)] angles: Item<DVec2>,
#[default(true)] connect_cells: Item<bool>,
) -> Item<Vector> {
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));
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<usize>| {
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<usize>| {
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<usize>| {
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));
}
}
}
@@ -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<DVec2> = 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::<f64>() / 2.;
assert!(signed_area > 0., "grid cell should wind counter-clockwise");
}
}
#[test]
fn qr_code_test() {
let qr = qr_code(