Skip to content

Instantly share code, notes, and snippets.

@hlipnick
Created August 5, 2025 21:02
Show Gist options
  • Save hlipnick/b809a6f71c7730c449eb3688e86e4400 to your computer and use it in GitHub Desktop.
Save hlipnick/b809a6f71c7730c449eb3688e86e4400 to your computer and use it in GitHub Desktop.
Gridfinity Stacking Script
#!/usr/bin/env python
#############
# GIST Upload 8/5/25
# Author: Harry Lipnick, harrylipnick.com, https://github.com/hlipnick/
# This script is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.
# You may not use this script for commercial purposes. You must provide attribution, and if you remix, transform,
# or build upon the material, you must distribute your contributions under the same license as the original.
# License details: https://creativecommons.org/licenses/by-nc-sa/4.0/
# FURTHER, THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# Copyright (c) 2025 Harry Lipnick
#############
import os
import trimesh
import numpy as np
import sys
import pathlib as Path
from trimesh.transformations import rotation_matrix, translation_matrix
import argparse
AXIS_VECTORS = {"x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1]}
def mirror_mesh(mesh: trimesh.Trimesh, axis: str) -> trimesh.Trimesh:
axis = axis.lower()
if axis not in AXIS_VECTORS:
raise ValueError("Axis must be one of x, y, z")
scale = np.ones(3)
idx = {"x": 0, "y": 1, "z": 2}[axis]
scale[idx] = -1
transform = np.eye(4)
transform[0:3, 0:3] = np.diag(scale)
mirrored = mesh.copy()
mirrored.apply_transform(transform)
mirrored.fix_normals()
return mirrored
def rotate_180_about_axis_through_centroid(
mesh: trimesh.Trimesh, axis: str
) -> trimesh.Trimesh:
"""
Rotate 180 degrees around the given axis passing through the mesh centroid.
"""
axis = axis.lower()
if axis not in AXIS_VECTORS:
raise ValueError("Axis must be one of x, y, z")
centroid = mesh.centroid
axis_vector = np.array(AXIS_VECTORS[axis], dtype=float)
# 180 degrees = pi radians
R = rotation_matrix(np.pi, axis_vector, point=centroid)
rotated = mesh.copy()
rotated.apply_transform(R)
rotated.fix_normals()
return rotated
def align_xy(original: trimesh.Trimesh, modified: trimesh.Trimesh) -> trimesh.Trimesh:
"""
Translate modified mesh in X/Y so its centroid in X/Y matches original's. Z untouched.
"""
orig_centroid = original.centroid
mod_centroid = modified.centroid
delta = orig_centroid - mod_centroid
translation = np.eye(4)
translation[0, 3] = delta[0]
translation[1, 3] = delta[1]
aligned = modified.copy()
aligned.apply_transform(translation)
return aligned
def stack_above_with_gap(
original: trimesh.Trimesh, copy: trimesh.Trimesh, gap: float
) -> trimesh.Trimesh:
"""
Move copy so its min Z is at original.max Z + gap.
"""
_, orig_max = original.bounds
copy_min, _ = copy.bounds
target_z = orig_max[2] + gap
delta_z = target_z - copy_min[2]
translation = np.eye(4)
translation[2, 3] = delta_z
moved = copy.copy()
moved.apply_transform(translation)
return moved
def center_mesh(input_mesh):
"""Center the mesh at the origin."""
mesh = trimesh.load(input_mesh)
(minx, miny, minz), (maxx, maxy, maxz) = mesh.bounds
cx = (minx + maxx) / 2
cy = (miny + maxy) / 2
# Translate so that the center of the mesh is at the origin
mesh.apply_translation([-cx, -cy, -minz])
return mesh
def working_directory():
path = "/tmp/gridfinity_stacked_grids"
Path.Path(path).mkdir(parents=True, exist_ok=True)
return path
def xy_sort(components):
tolerance = 10.0
column_centers = []
for comp in components:
x, y = comp.centroid[0], comp.centroid[1]
for cx in column_centers:
if abs(x - cx) < tolerance:
break
else:
column_centers.append(x)
column_centers.sort()
for comp in components:
# Find column index
component_col = min(range(len(column_centers)),
key=lambda i: abs(comp.centroid[0] - column_centers[i]))
comp.col = component_col
# Sort by column and then by y-coordinate
sorted_components = sorted(
components,
key=lambda c: (c.col, c.centroid[1]) # y ascending = bottom→top
)
return sorted_components
def split_stl_to_parts(input_path, output_dir = None, min_volume=10.0):
if output_dir is None:
output_dir = working_directory()
mesh = trimesh.load(input_path, force="mesh")
if isinstance(mesh, trimesh.Scene):
# concatenate all geometry into a single mesh so we can split by components
meshes = [g for g in mesh.geometry.values() if isinstance(g, trimesh.Trimesh)]
if not meshes:
raise ValueError(f"No mesh geometry found in scene from {input_path}")
mesh = trimesh.util.concatenate(meshes)
if not isinstance(mesh, trimesh.Trimesh):
raise ValueError(f"Could not interpret {input_path} as a mesh")
if mesh.is_empty:
raise ValueError("Loaded mesh is empty")
mesh.fix_normals()
prefix = Path.Path(input_path).stem
components = mesh.split(only_watertight=False) # splits disconnected pieces
components = [c for c in components if c.volume >= min_volume]
components = xy_sort(components)
if not components:
print("No separate components found; exporting original mesh as single file.")
target = output_dir / f"{prefix}_1.stl"
mesh.export(target, file_type="stl_ascii" if ascii else "stl")
print(f"Wrote {target}")
return [target]
written = 0
parts = []
for i, comp in enumerate(components):
comp_volume = comp.volume if comp.is_watertight else 0.0
if comp_volume < min_volume:
# skip too-small pieces if user requested
continue
comp.fix_normals()
name = f"{prefix}_Part{i + 1}"
target = output_dir / f"{name}.stl"
if ascii:
comp.export(target, file_type="stl_ascii")
else:
comp.export(target) # default binary
parts.append(target)
print(f"Wrote component {i}: {target} (volume: {comp_volume:.6g})")
written += 1
if written == 0:
print("No components met criteria (maybe min_volume too high).")
return parts
def main():
"""Script takes a GRIPS Gridfinity grid, splits into stacked copies"""
parser = argparse.ArgumentParser(
description="Mirror + rotate (about axis through centroid) + align + stack, with optional preview snapshots."
)
parser.add_argument("input", help="Input STL path")
parser.add_argument("output", help="Output STL path")
parser.add_argument(
"--mirror-axis",
choices=["x", "y", "z"],
default="y",
help="Axis to mirror across (default: y)",
)
parser.add_argument(
"--rotation-axis",
choices=["x", "y", "z"],
default="x",
help="Axis to rotate 180 degrees about (default: x)",
)
parser.add_argument(
"--gap",
type=float,
default=0.2,
help="Gap between original and transformed copy along +Z.",
)
parser.add_argument(
"--ascii",
action="store_true",
help="Export ASCII STL instead of binary.",
)
parser.add_argument(
"--preview",
action="store_true",
help="Emit PNG previews of each intermediate stage into same folder as output.",
)
parser.add_argument(
"--show",
action="store_true",
help="Open interactive viewer of final combined scene (requires local display).",
)
parser.add_argument(
"--copies",
type=int,
default=1,
help="Number of copies to stack above the original mesh (default: 1).",
)
args = parser.parse_args()
if not Path.Path(args.input).exists():
print(f"Input file {args.input} does not exist.")
sys.exit(1)
if not Path.Path(args.output).exists():
print(f"Output directory {args.output} does not exist. Creating it.")
Path.Path(args.output).mkdir(parents=True, exist_ok=True)
parts = split_stl_to_parts(args.input, output_dir=Path.Path(args.output))
for part in parts:
print(f"Processing part: {part} / {len(parts)}")
mesh = center_mesh(part)
# Always include the original mesh first
meshes = [mesh]
prev_mesh = mesh
for i in range(args.copies):
mirrored = mirror_mesh(mesh, args.mirror_axis)
rotated = rotate_180_about_axis_through_centroid(mirrored, args.rotation_axis)
aligned = align_xy(mesh, rotated)
stacked = stack_above_with_gap(prev_mesh, aligned, args.gap)
meshes.append(stacked)
prev_mesh = stacked
combined = trimesh.util.concatenate(meshes)
combined.update_faces(combined.unique_faces())
combined.fix_normals()
if args.ascii:
output_format = "stl_ascii"
else:
output_format = "stl"
output_file = Path.Path(args.output) / f"{part.stem}_{args.copies + 1}Cop{'ies' if args.copies + 1 > 1 else 'y'}Stacked.{output_format}"
print(f"Exporting combined mesh to {output_file}")
combined.export(output_file, file_type=output_format)
# Delete the parts
for part in parts:
print(f"Deleting temporary part file: {part}")
part.unlink()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment