mirror of
https://github.com/revarbat/BOSL2.git
synced 2025-08-05 04:37:25 +02:00
335 lines
13 KiB
Python
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()
|