From bf8ef423f7e4e39893f5f97861ae21b6692a55f6 Mon Sep 17 00:00:00 2001 From: Caitlyn O'Hanna Date: Wed, 11 Jun 2025 16:25:35 -0700 Subject: [PATCH] Improve mesh abstraction and CI --- .github/workflows/tests.yaml | 2 +- README.md | 1 + docs/development.md | 2 +- layerforge/cli.py | 1 + layerforge/models/loading/__init__.py | 4 +- .../loading/implementations/trimesh_loader.py | 4 +- layerforge/models/loading/mesh.py | 69 +++++++++++++++++++ .../reference_mark_calculator.py | 4 +- tests/test_cli.py | 19 +++++ tests/test_model_factory.py | 3 +- 10 files changed, 100 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ada4bdd..72baa9e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -17,6 +17,6 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e . -r requirements-dev.txt + pip install -e .[full] -r requirements-dev.txt - name: Run tests run: pytest -q diff --git a/README.md b/README.md index 3fc898a..a26d582 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # LayerForge [![Build Documentation using MkDocs](https://github.com/ravenoak/layerforge/actions/workflows/docs.yaml/badge.svg)](https://github.com/ravenoak/layerforge/actions/workflows/docs.yaml) +[![Run Tests](https://github.com/ravenoak/layerforge/actions/workflows/tests.yaml/badge.svg)](https://github.com/ravenoak/layerforge/actions/workflows/tests.yaml) ## Description diff --git a/docs/development.md b/docs/development.md index a90e8eb..701e83d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,7 +14,7 @@ - Slice the model into layers of a specified thickness. 4. **Reference Marks**: - - Reference marks should be placed at the centroid of each slice, where appropriate. + - Reference marks are placed using a stability metric that chooses points far from one another and from the contours. - Reference marks should be inherited from adjacent slices where possible, including the shape of the mark. - New reference marks should be a different shape when added to a slice where they are not inherited. - New reference marks must be introduced to a layer that does have a reference mark inherited from an adjacent slice, to ensure there is continuity in the reassembly process. diff --git a/layerforge/cli.py b/layerforge/cli.py index db37183..b84e94a 100644 --- a/layerforge/cli.py +++ b/layerforge/cli.py @@ -159,6 +159,7 @@ def cli( This function wraps all the logic for processing an STL file and generating SVG slices while accepting commandline arguments. + See ``README.md`` for example usage. Parameters ---------- diff --git a/layerforge/models/loading/__init__.py b/layerforge/models/loading/__init__.py index 7d90742..cced5e5 100644 --- a/layerforge/models/loading/__init__.py +++ b/layerforge/models/loading/__init__.py @@ -1,6 +1,6 @@ -__all__ = ["LoaderFactory", "Mesh", "TrimeshLoader"] +__all__ = ["LoaderFactory", "Mesh", "TrimeshMesh", "TrimeshLoader"] -from .mesh import Mesh +from .mesh import Mesh, TrimeshMesh from .implementations.trimesh_loader import TrimeshLoader from .base import MeshLoader diff --git a/layerforge/models/loading/implementations/trimesh_loader.py b/layerforge/models/loading/implementations/trimesh_loader.py index 9067527..cc7ba63 100644 --- a/layerforge/models/loading/implementations/trimesh_loader.py +++ b/layerforge/models/loading/implementations/trimesh_loader.py @@ -5,7 +5,7 @@ trimesh = require_module("trimesh", "TrimeshLoader") from layerforge.models.loading.base import MeshLoader -from layerforge.models.loading.mesh import Mesh +from layerforge.models.loading.mesh import TrimeshMesh, Mesh class TrimeshLoader(MeshLoader): @@ -30,4 +30,4 @@ def load_mesh(self, model_file: str) -> Mesh: raise ValueError( f"File '{model_file}' contains {len(mesh)} geometries; only a single mesh is supported." ) - return Mesh(mesh) + return TrimeshMesh(mesh) diff --git a/layerforge/models/loading/mesh.py b/layerforge/models/loading/mesh.py index 291908b..6c23381 100644 --- a/layerforge/models/loading/mesh.py +++ b/layerforge/models/loading/mesh.py @@ -1,7 +1,76 @@ +"""Mesh abstractions used by the loaders and slicing logic.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any, Sequence +class Mesh(ABC): + """Interface describing the operations required by :class:`Model`.""" + + @abstractmethod + def copy(self) -> "Mesh": + """Return a copy of the mesh.""" + + @abstractmethod + def apply_scale(self, scale: float) -> None: + """Scale the mesh in-place.""" + + @abstractmethod + def apply_translation(self, translation: Sequence[float]) -> None: + """Translate the mesh in-place.""" + + @property + @abstractmethod + def bounds(self) -> Any: + """Return the bounding box of the mesh.""" + + @property + @abstractmethod + def extents(self) -> Any: + """Return the extents of the mesh.""" + + @abstractmethod + def section( + self, plane_origin: Sequence[float], plane_normal: Sequence[float] + ) -> Any: + """Return a section of the mesh at the given plane.""" + + +@dataclass +class TrimeshMesh(Mesh): + """Wrapper around a :class:`trimesh.Trimesh` object.""" + + geometry: Any + + def copy(self) -> "TrimeshMesh": + return TrimeshMesh(self.geometry.copy()) + + def apply_scale(self, scale: float) -> None: + self.geometry.apply_scale(scale) + + def apply_translation(self, translation: Sequence[float]) -> None: + self.geometry.apply_translation(translation) + + @property + def bounds(self) -> Any: + return self.geometry.bounds + + @property + def extents(self) -> Any: + return self.geometry.extents + + def section( + self, plane_origin: Sequence[float], plane_normal: Sequence[float] + ) -> Any: + return self.geometry.section( + plane_origin=plane_origin, plane_normal=plane_normal + ) + + + @dataclass class Mesh: """Lightweight wrapper around an underlying mesh implementation.""" diff --git a/layerforge/models/reference_marks/reference_mark_calculator.py b/layerforge/models/reference_marks/reference_mark_calculator.py index 6d408ee..7b21459 100644 --- a/layerforge/models/reference_marks/reference_mark_calculator.py +++ b/layerforge/models/reference_marks/reference_mark_calculator.py @@ -27,7 +27,9 @@ class ReferenceMarkCalculator: The calculator evaluates candidate points inside each polygon and selects those that maximize a simple geometric stability metric. The metric used is inspired by GDOP (Geometric Dilution of Precision) and rewards points that - are well spread out. + are well spread out. Marks therefore rarely lie exactly at the centroid of + the contour; rather, candidates are sampled and the most stable arrangement + is chosen. """ @staticmethod diff --git a/tests/test_cli.py b/tests/test_cli.py index 36fae72..23f93da 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -130,3 +130,22 @@ def test_cli_invalid_layer_height(monkeypatch): ) assert result.exit_code != 0 assert "Invalid value for --layer-height" in result.output + + +def test_cli_missing_stl_file_error(tmp_path): + """Missing STL path should result in a non-zero exit code.""" + runner = CliRunner() + out_dir = tmp_path / "out" + result = runner.invoke( + cli, + [ + "--stl-file", + str(tmp_path / "missing.stl"), + "--layer-height", + "1.0", + "--output-folder", + str(out_dir), + ], + ) + assert result.exit_code != 0 + assert result.exception is not None diff --git a/tests/test_model_factory.py b/tests/test_model_factory.py index 29d85f5..ea08ae9 100644 --- a/tests/test_model_factory.py +++ b/tests/test_model_factory.py @@ -1,11 +1,10 @@ import pytest pytest.importorskip("trimesh") - import trimesh from layerforge.models.model_factory import ModelFactory from layerforge.models.loading.base import MeshLoader -from layerforge.models.loading.mesh import Mesh +from layerforge.models.loading.mesh import TrimeshMesh as Mesh class DummyLoader(MeshLoader):