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 Dennis Kobert
parent dd416f8663
commit 5a8fac33fd
3 changed files with 101 additions and 86 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

@@ -2307,33 +2307,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

@@ -306,84 +306,76 @@ fn grid<T: GridSpacing>(
#[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<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));
}
}
}
@@ -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<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(&(), (), "https://graphite.art".to_string(), false, 1., QRCodeErrorCorrectionLevel::Low, true);