File size: 8,813 Bytes
5a79264
 
 
 
 
 
29c82ca
 
5a79264
 
 
 
 
29c82ca
5a79264
 
 
 
 
 
 
 
 
 
29c82ca
5a79264
 
 
 
29c82ca
 
 
 
 
 
 
5a79264
29c82ca
 
5a79264
29c82ca
 
 
 
 
 
 
 
 
 
5a79264
29c82ca
 
 
 
 
5a79264
29c82ca
 
 
 
5a79264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c7f0b44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8478b27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import numpy as np
from shapely.geometry import Polygon
import trimesh

import pytest

from stl_slicer import (
    _compose_even_odd_polygons,
    calculate_z_levels,
    scale_factors_for_target_extents,
    scale_mesh,
    slice_stl_to_layers,
)


def test_calculate_z_levels_creates_single_layer_for_thin_mesh() -> None:
    z_values = calculate_z_levels(0.0, 0.01, 0.1)

    assert len(z_values) == 1
    assert 0.0 <= z_values[0] < 0.01


def test_slice_stl_to_layers_creates_layer_polygons(tmp_path) -> None:
    mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
    stl_path = tmp_path / "cube.stl"
    mesh.export(stl_path)

    stack = slice_stl_to_layers(stl_path, layer_height=0.5)

    assert len(stack.layers) == 4
    assert len(stack.z_values) == 4
    assert all(
        later > earlier
        for earlier, later in zip(stack.z_values, stack.z_values[1:])
    )
    for layer in stack.layers:
        assert layer.area == pytest.approx(4.0)

    (x_min, y_min, z_min), (x_max, y_max, z_max) = stack.bounds
    assert (x_max - x_min, y_max - y_min, z_max - z_min) == pytest.approx((2.0, 2.0, 2.0))
    assert stack.name == "cube"
    assert stack.layer_height == 0.5


def test_slice_stl_to_layers_applies_scale_factors(tmp_path) -> None:
    mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
    stl_path = tmp_path / "cube.stl"
    mesh.export(stl_path)

    stack = slice_stl_to_layers(
        stl_path,
        layer_height=0.5,
        scale_factors=(2.0, 1.0, 0.5),
    )

    bounds = np.array(stack.bounds)
    np.testing.assert_allclose(bounds[1] - bounds[0], (4.0, 2.0, 1.0))
    assert len(stack.layers) == 2
    assert stack.layers[0].area == pytest.approx(8.0)


def test_scale_mesh_matches_target_extents_and_preserves_min_corner() -> None:
    mesh = trimesh.creation.box(extents=(2.0, 4.0, 5.0))
    mesh.apply_translation((5.0, 6.0, 7.0))

    target_extents = (10.0, 8.0, 2.5)
    scale_factors = scale_factors_for_target_extents(mesh, target_extents)
    scaled = scale_mesh(mesh, scale_factors)

    np.testing.assert_allclose(scaled.extents, target_extents)
    np.testing.assert_allclose(scaled.bounds[0], mesh.bounds[0])


def test_compose_even_odd_polygons_preserves_holes() -> None:
    outer = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
    inner = Polygon([(3, 3), (7, 3), (7, 7), (3, 7)])

    composed = _compose_even_odd_polygons([outer, inner])

    assert len(composed) == 1
    assert composed[0].area == outer.area - inner.area
    assert len(composed[0].interiors) == 1


def test_slice_stl_unions_interpenetrating_bodies(tmp_path) -> None:
    # One STL packing several separate solids that overlap (e.g. the stripe
    # prisms of a flag part) must slice to their union — the whole-mesh
    # even-odd rule would XOR the overlap into a false hole.
    a = trimesh.creation.box(extents=(4.0, 2.0, 2.0))
    b = trimesh.creation.box(extents=(2.0, 4.0, 2.0))
    b.apply_translation((1.0, 0.0, 0.0))  # overlaps half of `a`
    stl_path = tmp_path / "cross.stl"
    trimesh.util.concatenate([a, b]).export(stl_path)

    stack = slice_stl_to_layers(stl_path, layer_height=1.0)

    # Union area: 8 + 8 - 2x2 overlap = 12 (XOR would give 8).
    for layer in stack.layers:
        assert layer.area == pytest.approx(12.0)
        assert all(not polygon.interiors for polygon in layer.geoms)


def test_slice_stl_subtracts_inverted_cavity_bodies(tmp_path) -> None:
    # A watertight body wound inside-out (negative volume) is a modeller's
    # cavity: it must stay a hole, not be unioned as a solid.
    outer = trimesh.creation.box(extents=(6.0, 6.0, 2.0))
    cavity = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
    cavity.invert()
    stl_path = tmp_path / "hollow.stl"
    trimesh.util.concatenate([outer, cavity]).export(stl_path)

    stack = slice_stl_to_layers(stl_path, layer_height=1.0)

    for layer in stack.layers:
        assert layer.area == pytest.approx(36.0 - 4.0)
        assert sum(len(polygon.interiors) for polygon in layer.geoms) == 1


def test_slice_stl_handles_abutting_cells_and_stray_open_quads(tmp_path) -> None:
    # Checkerboard-style STL: watertight cells that touch at edges/corners,
    # plus stray open quad fragments (internal walls). The cells must slice
    # per body and union into the exact checker pattern; the open quads
    # produce no closed rings and drop out.
    cells = []
    for cx, cy in ((0, 0), (1, 1), (2, 0), (0, 2), (2, 2)):
        cell = trimesh.creation.box(extents=(10.0, 10.0, 10.0))
        cell.apply_translation((cx * 10.0 + 5.0, cy * 10.0 + 5.0, 5.0))
        cells.append(cell)
    quad = trimesh.Trimesh(
        vertices=[(10.0, 0.0, 0.0), (10.0, 10.0, 0.0), (10.0, 10.0, 10.0), (10.0, 0.0, 10.0)],
        faces=[(0, 1, 2), (0, 2, 3)],
    )
    stl_path = tmp_path / "checker.stl"
    trimesh.util.concatenate(cells + [quad]).export(stl_path)

    stack = slice_stl_to_layers(stl_path, layer_height=1.0)

    for layer in stack.layers:
        assert layer.area == pytest.approx(500.0)
        assert layer.bounds == pytest.approx((0.0, 0.0, 30.0, 30.0))


def test_scale_mesh_about_an_explicit_anchor() -> None:
    mesh = trimesh.creation.box(extents=(2.0, 2.0, 2.0))
    mesh.apply_translation((6.0, 6.0, 6.0))  # spans 5..7 on every axis

    scaled = scale_mesh(mesh, (2.0, 2.0, 2.0), anchor=(0.0, 0.0, 0.0))

    # Scaling about the shared origin: 5..7 becomes 10..14 (own-corner
    # scaling would give 5..9 and shift the part within an assembly).
    np.testing.assert_allclose(scaled.bounds[0], (10.0, 10.0, 10.0))
    np.testing.assert_allclose(scaled.bounds[1], (14.0, 14.0, 14.0))


def test_slice_stl_scale_anchor_keeps_assembly_parts_together(tmp_path) -> None:
    # Two assembly parts side by side; both scaled x2 about the ASSEMBLY
    # corner must stay adjacent (B's min corner moves from 4 to 8).
    a = trimesh.creation.box(extents=(4.0, 4.0, 4.0))
    a.apply_translation((2.0, 2.0, 2.0))  # spans 0..4
    b = trimesh.creation.box(extents=(4.0, 4.0, 4.0))
    b.apply_translation((6.0, 2.0, 2.0))  # spans 4..8 in x
    path_a = tmp_path / "a.stl"
    path_b = tmp_path / "b.stl"
    a.export(path_a)
    b.export(path_b)

    anchor = (0.0, 0.0, 0.0)
    stack_a = slice_stl_to_layers(path_a, 1.0, scale_factors=(2.0, 2.0, 2.0), scale_anchor=anchor)
    stack_b = slice_stl_to_layers(path_b, 1.0, scale_factors=(2.0, 2.0, 2.0), scale_anchor=anchor)

    assert stack_a.bounds[0][0] == pytest.approx(0.0)
    assert stack_a.bounds[1][0] == pytest.approx(8.0)
    assert stack_b.bounds[0][0] == pytest.approx(8.0)  # still flush against A
    assert stack_b.bounds[1][0] == pytest.approx(16.0)


def test_flip_z_mirrors_the_shape_top_to_bottom(tmp_path) -> None:
    # Wide slab with a narrow tower on top; flipped, the tower prints first.
    slab = trimesh.creation.box(extents=(10.0, 10.0, 1.0))
    slab.apply_translation((5.0, 5.0, 0.5))       # z 0..1
    tower = trimesh.creation.box(extents=(2.0, 2.0, 1.0))
    tower.apply_translation((5.0, 5.0, 1.5))      # z 1..2
    stl_path = tmp_path / "tower.stl"
    trimesh.util.concatenate([slab, tower]).export(stl_path)

    normal = slice_stl_to_layers(stl_path, layer_height=1.0)
    flipped = slice_stl_to_layers(stl_path, layer_height=1.0, flip_z=True)

    assert [round(layer.area) for layer in normal.layers] == [100, 4]
    assert [round(layer.area) for layer in flipped.layers] == [4, 100]
    # Flip about the own midplane preserves the Z range.
    assert flipped.bounds[0][2] == pytest.approx(normal.bounds[0][2])
    assert flipped.bounds[1][2] == pytest.approx(normal.bounds[1][2])


def test_flip_z_about_a_group_midplane_flips_the_assembly_as_one(tmp_path) -> None:
    # Two assembly parts at different heights flip about the SHARED midplane:
    # the part that was on top lands on the bottom of the shared Z range.
    low = trimesh.creation.box(extents=(4.0, 4.0, 1.0))
    low.apply_translation((2.0, 2.0, 0.5))   # z 0..1
    high = trimesh.creation.box(extents=(4.0, 4.0, 1.0))
    high.apply_translation((6.0, 2.0, 2.5))  # z 2..3
    path_low = tmp_path / "low.stl"
    path_high = tmp_path / "high.stl"
    low.export(path_low)
    high.export(path_high)

    group_mid = 1.5  # shared z range 0..3
    z_levels = [0.5, 1.5, 2.5]
    stack_low = slice_stl_to_layers(path_low, 1.0, z_levels=z_levels, flip_z=True, z_flip_mid=group_mid)
    stack_high = slice_stl_to_layers(path_high, 1.0, z_levels=z_levels, flip_z=True, z_flip_mid=group_mid)

    # `low` (was z 0..1) now occupies z 2..3; `high` now z 0..1.
    assert [layer.is_empty for layer in stack_low.layers] == [True, True, False]
    assert [layer.is_empty for layer in stack_high.layers] == [False, True, True]
    assert stack_high.layers[0].area == pytest.approx(16.0)