ParallelPrint / stl_slicer.py
CyGuy8's picture
Sync multi-material assemblies in the table, add Flip Z, fix split seam gaps
8478b27
Raw
History Blame Contribute Delete
13.9 kB
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Sequence
import numpy as np
from shapely.geometry import GeometryCollection, MultiPolygon, Polygon
from shapely.ops import unary_union
from shapely.validation import make_valid
import trimesh
ProgressCallback = Callable[[int, int], None] | None
ScaleFactors = tuple[float, float, float]
Bounds3D = tuple[tuple[float, float, float], tuple[float, float, float]]
MIN_POLYGON_AREA = 1e-9
@dataclass(slots=True)
class LayerStack:
"""A sliced shape as per-layer vector outlines in world-XY millimetres.
`bounds` is the scaled mesh's axis-aligned bounding box. For grid-split
pieces it is the piece's nominal cell box, which keeps reference-stack
centring stable even when the clipped geometry shrinks.
`scan_frame` is the XY box the raster scan grid is anchored to. Grid-split
pieces inherit the parent shape's frame so all pieces raster on one
continuous line grid and seams keep an exact one-fil-width pitch.
`align_center`/`align_grid` are stamped by `build_reference_stack` on the
combined reference stack: the common centre shapes were aligned to, and
the grid the alignment deltas were snapped to — so later alignment of a
shape against the reference reproduces exactly the same translation.
"""
layers: list[MultiPolygon]
z_values: list[float]
bounds: Bounds3D
layer_height: float
name: str = ""
scan_frame: tuple[float, float, float, float] | None = None
align_center: tuple[float, float] | None = None
align_grid: float | None = None
# Multi-material group frame: the combined XY bbox of every part sharing
# this shape's nozzle. Group members all carry the SAME frame and are
# aligned by its centre instead of their own bbox centre, so they move as
# one rigid unit and keep their modelled positions relative to each
# other. None = align by the shape's own bbox centre (normal shapes).
align_frame: tuple[float, float, float, float] | None = None
# Grid-split pieces: per-layer contour polylines from the PARENT shape's
# boundary clipped to this piece's cell — cut seams between sibling
# pieces are excluded, so contour tracing only outlines the true outer
# surface. None means "derive contours from the layer polygons" (whole
# shapes). Paths may be open arcs; closed rings keep first == last.
contour_paths: list[list[list[tuple[float, float]]]] | None = None
def load_mesh(stl_path: str | Path) -> trimesh.Trimesh:
loaded = trimesh.load(stl_path, force="scene")
if isinstance(loaded, trimesh.Scene):
if not loaded.geometry:
raise ValueError("The STL file does not contain any mesh geometry.")
mesh = trimesh.util.concatenate(tuple(loaded.geometry.values()))
else:
mesh = loaded
if not isinstance(mesh, trimesh.Trimesh) or mesh.is_empty:
raise ValueError("Unable to load a valid mesh from the STL file.")
return mesh
def _normalize_scale_factors(scale_factors: Sequence[float] | None) -> ScaleFactors:
if scale_factors is None:
return (1.0, 1.0, 1.0)
values = tuple(float(value) for value in scale_factors)
if len(values) != 3:
raise ValueError("Scale factors must contain X, Y, and Z values.")
if any(value <= 0 for value in values):
raise ValueError("Scale factors must be greater than zero.")
return (values[0], values[1], values[2])
def scale_mesh(
mesh: trimesh.Trimesh,
scale_factors: Sequence[float] | None = None,
anchor: Sequence[float] | None = None,
) -> trimesh.Trimesh:
"""Return a copy of `mesh` scaled around `anchor` (its own minimum XYZ
corner by default).
Multi-material assembly parts pass their GROUP's combined corner: parts
scaled by the same factors about one shared point stay assembled, while
each scaling about its own corner would shift them relative to each
other.
"""
sx, sy, sz = _normalize_scale_factors(scale_factors)
scaled = mesh.copy()
if math.isclose(sx, 1.0) and math.isclose(sy, 1.0) and math.isclose(sz, 1.0):
return scaled
anchor = np.asarray(
mesh.bounds[0] if anchor is None else anchor, dtype=float
)
transform = np.eye(4)
transform[0, 0] = sx
transform[1, 1] = sy
transform[2, 2] = sz
transform[:3, 3] = anchor * (1.0 - np.array([sx, sy, sz], dtype=float))
scaled.apply_transform(transform)
return scaled
def scale_factors_for_target_extents(
mesh: trimesh.Trimesh,
target_extents: Sequence[float],
) -> ScaleFactors:
target = _normalize_scale_factors(target_extents)
extents = np.asarray(mesh.extents, dtype=float)
if np.any(extents <= 0):
raise ValueError("Cannot scale a mesh with a zero-sized X, Y, or Z extent.")
return (
target[0] / float(extents[0]),
target[1] / float(extents[1]),
target[2] / float(extents[2]),
)
def calculate_z_levels(z_min: float, z_max: float, layer_height: float) -> list[float]:
if layer_height <= 0:
raise ValueError("Layer height must be greater than zero.")
thickness = z_max - z_min
if thickness <= 0:
return [z_min]
layer_count = max(1, math.ceil(thickness / layer_height))
top_guard = math.nextafter(z_max, z_min)
return [
min(z_min + ((index + 0.5) * layer_height), top_guard)
for index in range(layer_count)
]
def _ring_to_world_xy(ring_coords: object, to_3d: np.ndarray) -> np.ndarray:
planar = np.asarray(ring_coords, dtype=float)
if planar.ndim != 2 or planar.shape[1] < 2:
raise ValueError("Encountered an invalid polygon ring while slicing.")
planar_3d = np.column_stack([planar[:, 0], planar[:, 1], np.zeros(len(planar))])
world = trimesh.transform_points(planar_3d, to_3d)
return world[:, :2]
def _compose_even_odd_polygons(polygons: list[Polygon]) -> list[Polygon]:
geometry: Polygon | MultiPolygon | GeometryCollection | None = None
for polygon in polygons:
geometry = polygon if geometry is None else geometry.symmetric_difference(polygon)
if geometry is None or geometry.is_empty:
return []
if isinstance(geometry, Polygon):
return [geometry]
if isinstance(geometry, MultiPolygon):
return list(geometry.geoms)
if isinstance(geometry, GeometryCollection):
return [geom for geom in geometry.geoms if isinstance(geom, Polygon) and not geom.is_empty]
return []
def _extract_world_polygons(section: trimesh.path.Path3D) -> list[tuple[np.ndarray, list[np.ndarray]]]:
if hasattr(section, "to_2D"):
planar, to_3d = section.to_2D()
else:
planar, to_3d = section.to_planar()
# polygons_full composes rings by containment depth (ring in a ring is a
# hole, ring in a hole is an island), so rings from separate solids that
# merely OVERLAP each other stay separate solids — the caller unions
# them. A flat even-odd XOR across all rings would punch false holes
# wherever interpenetrating solids overlap (e.g. flag stripe prisms
# packed into one STL).
try:
composed_polygons = [
polygon
for polygon in planar.polygons_full
if polygon is not None and not polygon.is_empty
]
except BaseException:
composed_polygons = _compose_even_odd_polygons(list(planar.polygons_closed))
polygons: list[tuple[np.ndarray, list[np.ndarray]]] = []
for polygon in composed_polygons:
exterior = _ring_to_world_xy(polygon.exterior.coords, to_3d)
holes = [_ring_to_world_xy(interior.coords, to_3d) for interior in polygon.interiors]
polygons.append((exterior, holes))
return polygons
def _as_multipolygon(geometry: object, min_area: float = MIN_POLYGON_AREA) -> MultiPolygon:
"""Flatten any shapely result into a MultiPolygon, dropping slivers.
Single choke point for shapely's habit of returning mixed geometry types
from overlay operations (Polygon / MultiPolygon / GeometryCollection /
lines / points / empties).
"""
polygons: list[Polygon] = []
def collect(geom: object) -> None:
if geom is None or getattr(geom, "is_empty", True):
return
if isinstance(geom, Polygon):
if geom.area > min_area:
polygons.append(geom)
elif isinstance(geom, (MultiPolygon, GeometryCollection)):
for part in geom.geoms:
collect(part)
collect(geometry)
return MultiPolygon(polygons)
def _section_to_multipolygon(section: trimesh.path.Path3D | None) -> MultiPolygon:
if section is None:
return MultiPolygon()
polygons = [
make_valid(Polygon(exterior, holes))
for exterior, holes in _extract_world_polygons(section)
]
if not polygons:
return MultiPolygon()
# Union, not just collect: polygons from interpenetrating solids overlap.
return _as_multipolygon(make_valid(unary_union(polygons)))
def _split_solids_and_cavities(
mesh: trimesh.Trimesh,
) -> tuple[list[trimesh.Trimesh], list[trimesh.Trimesh]]:
"""Connected bodies of a mesh, split into solids and explicit cavities.
A single STL often packs several separate solids that touch or
interpenetrate (checkerboard cells, overlapping stripe prisms).
Sectioning such a mesh whole corrupts the ring reconstruction where
rings cross or meet, so each WATERTIGHT body is sliced separately and
the results unioned; a watertight body wound inside-out (negative
volume) is a modeller's cavity, subtracted instead of unioned.
Everything that is not watertight — stray internal quads, meshes whose
face connectivity shreds into open fragments (T-vertices, unstitched
fans) — is kept together as ONE remainder mesh and sectioned whole,
where ring reconstruction can stitch across fragment boundaries; open
slivers there simply produce no closed rings and drop out.
"""
try:
bodies = [body for body in mesh.split(only_watertight=False) if len(body.faces)]
except Exception:
return [mesh], []
if len(bodies) <= 1:
return [mesh], []
solids: list[trimesh.Trimesh] = []
cavities: list[trimesh.Trimesh] = []
remainder: list[trimesh.Trimesh] = []
for body in bodies:
if not body.is_watertight:
remainder.append(body)
elif body.is_winding_consistent and float(body.volume) < 0.0:
cavities.append(body)
else:
solids.append(body)
if remainder:
solids.append(
trimesh.util.concatenate(remainder) if len(remainder) > 1 else remainder[0]
)
return solids, cavities
def slice_stl_to_layers(
stl_path: str | Path,
layer_height: float,
progress_callback: ProgressCallback = None,
scale_factors: Sequence[float] | None = None,
name: str | None = None,
z_levels: Sequence[float] | None = None,
scale_anchor: Sequence[float] | None = None,
flip_z: bool = False,
z_flip_mid: float | None = None,
) -> LayerStack:
"""Slice an STL into per-layer vector outlines (world-XY millimetres).
`z_levels` overrides the per-mesh Z planes with an explicit (world) grid —
used by multi-material assemblies so every part slices on ONE shared grid
and parts that start higher simply get empty lower layers. `scale_anchor`
is the point target-dimension scaling happens about (assembly parts share
their group's corner so they stay assembled when rescaled).
`flip_z` mirrors the scaled mesh about the horizontal plane at
`z_flip_mid` (its own Z midpoint by default) — printing the shape the
other way up. Assembly parts pass their GROUP's midplane so the whole
assembly flips as one unit.
"""
stl_path = Path(stl_path)
mesh = scale_mesh(load_mesh(stl_path), scale_factors, anchor=scale_anchor)
if flip_z:
mid = (
float(z_flip_mid)
if z_flip_mid is not None
else (float(mesh.bounds[0][2]) + float(mesh.bounds[1][2])) / 2.0
)
mirror = np.eye(4)
mirror[2, 2] = -1.0
mirror[2, 3] = 2.0 * mid
# apply_transform reverses face winding for negative determinants,
# so normals stay outward and cavity detection keeps working.
mesh.apply_transform(mirror)
(x_min, y_min, z_min), (x_max, y_max, z_max) = mesh.bounds
if z_levels is not None:
z_values = [float(z) for z in z_levels]
else:
z_values = calculate_z_levels(float(z_min), float(z_max), layer_height)
solids, cavities = _split_solids_and_cavities(mesh)
def section_at(body: trimesh.Trimesh, z_value: float) -> MultiPolygon:
return _section_to_multipolygon(
body.section(
plane_origin=np.array([0.0, 0.0, z_value], dtype=float),
plane_normal=np.array([0.0, 0.0, 1.0], dtype=float),
)
)
layers: list[MultiPolygon] = []
for index, z_value in enumerate(z_values):
layer = unary_union([section_at(body, z_value) for body in solids])
if cavities:
layer = layer.difference(
unary_union([section_at(body, z_value) for body in cavities])
)
layers.append(_as_multipolygon(make_valid(layer)))
if progress_callback is not None:
progress_callback(index + 1, len(z_values))
return LayerStack(
layers=layers,
z_values=z_values,
bounds=(
(float(x_min), float(y_min), float(z_min)),
(float(x_max), float(y_max), float(z_max)),
),
layer_height=layer_height,
name=name if name is not None else (stl_path.stem or "mesh"),
)