File size: 13,893 Bytes
5a79264
 
 
 
 
 
 
 
 
c7f0b44
29c82ca
5a79264
 
 
 
 
29c82ca
 
 
5a79264
 
 
29c82ca
 
 
 
 
 
4d202b8
 
 
 
 
 
 
 
 
29c82ca
 
 
5a79264
29c82ca
5a79264
29c82ca
4d202b8
 
 
c7f0b44
 
 
 
 
 
859a75d
 
 
 
 
 
5a79264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8478b27
 
 
 
 
 
 
 
 
 
 
 
 
5a79264
 
 
 
 
 
8478b27
 
 
5a79264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
5a79264
 
 
 
 
 
 
 
 
29c82ca
 
5a79264
29c82ca
 
 
 
 
5a79264
29c82ca
 
 
 
 
 
 
 
 
5a79264
29c82ca
 
5a79264
 
29c82ca
 
 
5a79264
29c82ca
c7f0b44
29c82ca
 
 
 
5a79264
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5a79264
 
29c82ca
5a79264
 
 
 
29c82ca
c7f0b44
8478b27
 
 
29c82ca
c7f0b44
 
 
 
8478b27
 
 
 
 
 
 
 
c7f0b44
5a79264
8478b27
 
 
 
 
 
 
 
 
 
 
 
 
29c82ca
5a79264
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
5a79264
29c82ca
5a79264
c7f0b44
 
 
 
 
 
5a79264
 
 
 
29c82ca
 
5a79264
 
 
 
 
 
29c82ca
5a79264
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
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"),
    )