Files
BOSL2/scripts/3d2scad.py

335 lines
13 KiB
Python

# 3d2scad.py - convert STL or 3MF to OpenSCAD polyhedron arrays.
#
# This utility does these things (in this order):
# - creates list of vertices and faces as the mesh is loaded
# - separates object into shells if multiple objects are detected
# - removes invalid triangles
# - optionally simplifies mesh (reduces polygon count) using quadric decimation (a robust method of simplification)
# - attempts repairs if a shell is detected as non-watertight (fill holes, remove unreferenced vertices, fix inversion and winding order, remove duplicate faces)
# - ensure normals are consistently pointing outward
# - quantizes coordinates to nearest 0.001 (or whatever you specify) for more compact output
# - removes zero-area triangles
# - removes duplicate vertices for significant size reduction (often a STL vertex is repeated six times)
# - another pass of removing unreferenced vertices
# - removes shared edges from coplanar polygons
# - outputs a text file with a raw list of polyhedron structures (NOT an .scad file); see below for usage.
#
# In some cases, the operations above can result in non-manifold shapes, such as when two objects
# share an edge, the resulting edge may be shared by more than two faces.
#
# June 2025
# TO USE IN OPENSCAD WITH BOLS2 LIBRARY:
# See VNF documentation at https://github.com/BelfrySCAD/BOSL2/wiki/vnf.scad
# If your output file is "model.txt" then use it this way:
#
# include <BOSL2/std.scad>
# vnf_list = include <model.txt>; // end with semicolon
# // vnf_list now contains a list of VNF (OpenSCAD polyhedron) structures
# vnf_polyhedron(vnf_list);
import sys
REQUIRED = ["numpy", "scipy", "trimesh", "open3d", "networkx", "lxml"] # required libraries not typically included in Python
MISSING = []
for pkg in REQUIRED:
try:
__import__(pkg)
except ImportError:
MISSING.append(pkg)
if MISSING:
print("Missing required Python packages:", ", ".join(MISSING))
print("Please install (as administrator) using:")
print(f" pip install {' '.join(MISSING)}")
sys.exit(1)
import argparse
import numpy as np
import trimesh
import open3d as o3d
from scipy.spatial import cKDTree
from collections import defaultdict, deque
import os
def load_mesh(filename):
print(f"Loading {filename}", flush=True)
mesh = trimesh.load_mesh(filename, process=False)
print(f"Loaded mesh with {len(mesh.vertices)} vertices and {len(mesh.faces)} faces,", flush=True)
mesh = trimesh.load_mesh(filename, process=True)
if isinstance(mesh, trimesh.Scene):
mesh = trimesh.util.concatenate(tuple(mesh.dump().geometry.values()))
print(f"reduced to {len(mesh.vertices)} vertices and {len(mesh.faces)} faces", flush=True)
bounds = mesh.bounds
min_corner = bounds[0]
max_corner = bounds[1]
bbox_str = "[[" + ",".join(format_number(x, 6) for x in min_corner) + "],[" + ",".join(format_number(x, 6) for x in max_corner) + "]]"
print(f" Bounding box: {bbox_str}", flush=True)
return mesh
def split_into_shells(mesh):
shells = mesh.split(only_watertight=False)
if len(shells)==1:
print("One shell found", flush=True)
else:
print(f"Split into {len(shells)} shells", flush=True)
return shells
def remove_invalid_triangles(mesh):
original_count = len(mesh.faces)
v = mesh.vertices[mesh.faces] # shape (N, 3, 3)
same01 = np.all(v[:, 0] == v[:, 1], axis=1)
same12 = np.all(v[:, 1] == v[:, 2], axis=1)
same20 = np.all(v[:, 2] == v[:, 0], axis=1)
invalid = same01 | same12 | same20
mesh.faces = mesh.faces[~invalid]
removed = np.count_nonzero(invalid)
print(f" Removed {removed} invalid triangle{'s' if removed != 1 else ''}", flush=True)
return mesh
def decimate_mesh(mesh, target_reduction=0.5):
print(f" Performing quadric edge collapse decimation (target reduction: {target_reduction})", flush=True)
mesh_o3d = o3d.geometry.TriangleMesh()
mesh_o3d.vertices = o3d.utility.Vector3dVector(mesh.vertices)
mesh_o3d.triangles = o3d.utility.Vector3iVector(mesh.faces)
mesh_o3d.remove_duplicated_vertices()
mesh_o3d.remove_duplicated_triangles()
mesh_o3d.remove_degenerate_triangles()
mesh_o3d.remove_non_manifold_edges()
target_count = int(len(mesh.faces) * (1 - target_reduction))
simplified = mesh_o3d.simplify_quadric_decimation(target_count)
simplified.remove_duplicated_vertices()
simplified.remove_duplicated_triangles()
simplified.remove_degenerate_triangles()
simplified.remove_non_manifold_edges()
mesh.vertices = np.asarray(simplified.vertices)
mesh.faces = np.asarray(simplified.triangles)
print(f" Resulting mesh has {len(mesh.vertices)} vertices and {len(mesh.faces)} faces", flush=True)
return mesh
def quantize_vertices(mesh, grid_size):
print(f" Quantizing vertices to grid size {grid_size}", flush=True)
mesh.vertices = np.round(mesh.vertices / grid_size) * grid_size
return mesh
def remove_zero_area_triangles(mesh):
original_count = len(mesh.faces)
areas = trimesh.triangles.area(mesh.triangles)
mask = areas > 1e-12
mesh.faces = mesh.faces[mask]
removed = original_count - len(mesh.faces)
print(f" Removed {removed} zero-area triangle{'s' if removed != 1 else ''}", flush=True)
return mesh
def face_normal(v0, v1, v2):
return np.cross(v1 - v0, v2 - v0)
def merge_coplanar_triangles(vertices, triangles, normal_tolerance=1e-4):
print(" Merging coplanar triangles", flush=True)
edge_to_triangles = defaultdict(list)
face_normals = []
for idx, tri in enumerate(triangles):
v0, v1, v2 = vertices[tri[0]], vertices[tri[1]], vertices[tri[2]]
normal = face_normal(v0, v1, v2)
normal /= np.linalg.norm(normal) + 1e-12
face_normals.append(normal)
for i in range(3):
a, b = tri[i], tri[(i + 1) % 3]
key = tuple(sorted((a, b)))
edge_to_triangles[key].append(idx)
used = set()
triangle_groups = []
for i in range(len(triangles)):
if i in used:
continue
group = [i]
queue = deque([i])
used.add(i)
while queue:
curr = queue.pop()
tri = triangles[curr]
for j in range(3):
a, b = tri[j], tri[(j + 1) % 3]
key = tuple(sorted((a, b)))
neighbors = edge_to_triangles[key]
for nbr in neighbors:
if nbr in used:
continue
dot = np.dot(face_normals[curr], face_normals[nbr])
if dot >= 1.0 - normal_tolerance:
used.add(nbr)
queue.append(nbr)
group.append(nbr)
triangle_groups.append(group)
merged_groups = sum(1 for g in triangle_groups if len(g) > 1)
total_merged = sum(len(g) for g in triangle_groups if len(g) > 1)
print(f" Found {merged_groups} coplanar group{'s' if merged_groups != 1 else ''} with total {total_merged} triangle{'s' if total_merged != 1 else ''} merged", flush=True)
final_polys = []
for group in triangle_groups:
edge_count = {}
for idx in group:
tri = triangles[idx]
for i in range(3):
a, b = tri[i], tri[(i + 1) % 3]
key = (a, b)
rev = (b, a)
if rev in edge_count:
del edge_count[rev]
else:
edge_count[key] = (a, b)
if len(edge_count) < 3:
continue
edges = {a: b for a, b in edge_count.values()}
if not edges:
continue
start = next(iter(edges))
loop = [start]
current = start
while current in edges:
next_vertex = edges[current]
if next_vertex == loop[0]:
loop.append(next_vertex)
break
if next_vertex in loop: # invalid if encountered twice before closing
loop = []
break
loop.append(next_vertex)
del edges[current]
current = next_vertex
if len(loop) > 1000:
loop = []
break
if len(loop) >= 4 and loop[0] == loop[-1]:
final_polys.append(loop[:-1][::-1])
print(f" Constructed {len(final_polys)} final polygon{'s' if len(final_polys) != 1 else ''}", flush=True)
return final_polys
def format_number(n, precision):
fmt = f"{{:.{precision}f}}"
s = fmt.format(n).rstrip('0').rstrip('.')
if s.startswith("-0."):
s = "-" + s[2:]
elif s.startswith("0."):
s = s[1:]
elif s == "-0":
s = "0"
return s
def export_openscad_structure(vertices, polygons, nshells, shell_index, precision, f):
if shell_index == 0:
f.write("[ ")
f.write("\n[[")
f.write(",".join("[" + ",".join(format_number(c, precision) for c in v) + "]" for v in vertices))
f.write("],\n[")
f.write(",".join("[" + ",".join(str(i) for i in poly) + "]" for poly in polygons))
f.write("]]")
if shell_index < nshells-1:
f.write(",\n")
else:
f.write(f"\n// shells: {nshells}\n]\n")
print(f" Wrote shell {shell_index+1} with {len(vertices)} vertices and {len(polygons)} faces", flush=True)
def main():
parser = argparse.ArgumentParser(description="3D model to OpenSCAD polyhedron converter", formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument("input", help="Input STL or 3MF file")
parser.add_argument("output", help="Output data file (list of VNF structures)")
parser.add_argument("--polycount", type=float, metavar="FRAC", default=0.0,
help="Fraction of faces to remove via quadric decimation (0-0.9)")
parser.add_argument("--quantize", type=float, metavar="GRIDUNIT", default=0.001,
help="Grid size to quantize vertices")
parser.add_argument("--merge-shells", type=float, metavar="DIST",
help="Merge nearby shells within given distance")
parser.add_argument("--min-faces", type=int, metavar="FACES", default=4,
help="Minimum number of faces per shell to include in output")
args = parser.parse_args()
precision = max(0, -int(np.floor(np.log10(args.quantize)))) if args.quantize > 0 else 6
name = os.path.splitext(os.path.basename(args.output))[0]
mesh = load_mesh(args.input)
shells = split_into_shells(mesh)
nshells = len(shells)
if args.merge_shells:
merged = []
used = [False] * len(shells)
for i, a in enumerate(shells):
if used[i]:
continue
group = [a]
tree_a = cKDTree(a.vertices)
used[i] = True
for j in range(i+1, len(shells)):
if used[j]:
continue
b = shells[j]
tree_b = cKDTree(b.vertices)
if tree_a.sparse_distance_matrix(tree_b, args.merge_shells).nnz > 0:
group.append(b)
used[j] = True
if len(group) == 1:
merged.append(a)
else:
combined = trimesh.util.concatenate(group)
merged.append(combined)
shells = merged
print(f"Merged into {len(shells)} shell{'s' if len(shells) != 1 else ''}", flush=True)
with open(args.output, 'w') as f:
for i, shell in enumerate(shells):
print(f"Processing shell {i + 1}:", flush=True)
shell = remove_invalid_triangles(shell)
if args.polycount > 0:
shell = decimate_mesh(shell, args.polycount)
if not shell.is_watertight:
print(" Mesh is not watertight after simplification; attempting repair...", flush=True)
trimesh.repair.fill_holes(shell)
shell.remove_unreferenced_vertices()
trimesh.repair.fix_inversion(shell)
trimesh.repair.fix_winding(shell)
shell.remove_duplicate_faces()
if shell.is_watertight:
print(" -> Repair successful, now watertight.", flush=True)
else:
print(" -> Repair attempted, still not watertight.", flush=True)
trimesh.repair.fix_normals(shell)
shell = quantize_vertices(shell, args.quantize)
shell = remove_zero_area_triangles(shell)
shell.remove_unreferenced_vertices()
if len(shell.faces) < args.min_faces:
print(f" Skipping shell with only {len(shell.faces)} face{'s' if len(shell.faces) != 1 else ''}", flush=True)
nshells = nshells-1
continue
print(f" Diagnostics:")
print(f" - Watertight: {shell.is_watertight}")
print(f" - Euler number: {shell.euler_number}", flush=True)
if shell.is_watertight:
genus = (2 - shell.euler_number) // 2
print(f" - Genus: {int(genus)}")
polygons = merge_coplanar_triangles(shell.vertices, shell.faces)
export_openscad_structure(shell.vertices.tolist(), polygons, nshells, i, precision, f)
if __name__ == "__main__":
main()