Created
August 5, 2025 21:02
-
-
Save hlipnick/b809a6f71c7730c449eb3688e86e4400 to your computer and use it in GitHub Desktop.
Gridfinity Stacking Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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