Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions layerforge/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------
Expand Down
4 changes: 2 additions & 2 deletions layerforge/models/loading/__init__.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
4 changes: 2 additions & 2 deletions layerforge/models/loading/implementations/trimesh_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
69 changes: 69 additions & 0 deletions layerforge/models/loading/mesh.py
Original file line number Diff line number Diff line change
@@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 1 addition & 2 deletions tests/test_model_factory.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down