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)