Spaces:
Running
Running
| 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 | |
| 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"), | |
| ) | |