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