diff --git a/.coveragerc b/.coveragerc index b34815e..31978e4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,5 +1,4 @@ [report] -fail_under = 100 exclude_lines = pragma: no cover def __repr__ @@ -12,6 +11,13 @@ exclude_lines = if __name__ == .__main__.: @overload + [run] source = stl branch = True +parallel = true + +[paths] +source = + stl/ + */stl/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..475fc43 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12', '3.13'] + numpy-version: ['numpy1', 'numpy2'] + exclude: + - python-version: '3.13' + numpy-version: 'numpy1' + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + python-version: ${{ matrix.python-version }} + - name: Install tox + run: uv pip install '.[tox]' + - name: Run tests + run: tox -e py$(echo "${{ matrix.python-version }}" | tr -d .)-${{ matrix.numpy-version }} + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }}-${{ matrix.numpy-version }} + path: .tox/.coverage.* + include-hidden-files: true + + speedups: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Install tox + run: uv pip install '.[tox]' + - name: Run tests with speedups + run: tox -e speedups + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-speedups + path: .tox/.coverage.* + include-hidden-files: true + + coverage: + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [test, speedups] + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Install tox + run: uv pip install '.[tox]' + - uses: actions/download-artifact@v4 + with: + pattern: coverage-* + path: .tox + merge-multiple: true + - name: Combine coverage and check threshold + run: tox -e coverage + + lint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Install tox + run: uv pip install '.[tox]' + - name: Lint + run: tox -e lint + + type-check: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Install tox + run: uv pip install '.[tox]' + - name: Type-check + run: tox -e pyrefly + + docs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Install tox + run: uv pip install '.[tox]' + - name: Build docs + run: tox -e docs + - name: Run docs examples + run: tox -e docs-examples diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 2e3d3eb..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: tox - -on: - push: - pull_request: - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 10 - strategy: - matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] - numpy-version: ['numpy1', 'numpy2'] - - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: python version - env: - TOXENV: "py${{ matrix.python }}-${{ matrix.numpy-version }}" - run: | - TOXENV=${{ env.TOXENV }} - TOXENV=${TOXENV//.} # replace all dots - echo TOXENV=${TOXENV} >> $GITHUB_ENV # update GitHub ENV vars - - name: print env - run: echo ${{ env.TOXENV }} - - name: Test with tox - run: tox diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..72eb4cc --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,35 @@ +name: Publish to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v7 + with: + activate-environment: true + - name: Build + run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/type_check.yml b/.github/workflows/type_check.yml deleted file mode 100644 index 57cd37b..0000000 --- a/.github/workflows/type_check.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Type-check - -on: - push: - branches: [master, develop] - pull_request: - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: true - -jobs: - type-check: - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v6 - - - uses: astral-sh/setup-uv@v7 - with: - activate-environment: true - - - name: install - run: uv pip install cython basedpyright pyrefly mypy . - - - name: run basedpyright - run: basedpyright - - - name: run basedpyright --verifytypes - run: basedpyright --ignoreexternal --verifytypes stl - - - name: run pyrefly check - run: pyrefly check - - - name: run mypy - run: mypy --no-incremental --cache-dir=/dev/null stl diff --git a/.gitignore b/.gitignore index 84fc547..e65f22c 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ cover /*.stl /.tox /stl/*.c +uv.lock +benchmarks/.cache/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..cec3730 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +# Read the Docs configuration file +# https://docs.readthedocs.io/en/stable/config-file/v2.html +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: '3.13' + +sphinx: + configuration: docs/conf.py + +python: + install: + - requirements: docs/requirements.txt + - method: pip + path: . diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..97a7f17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,75 @@ +# Changelog + +All notable changes to numpy-stl are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [3.2.0] - 2024-11-25 + +### Fixed +- ASCII STL save compatibility with NumPy 2.x + +### Changed +- Test matrix: separate NumPy 1.x and 2.x CI jobs + +## [3.1.0] - 2023-11-08 + +### Fixed +- Minor bug fixes and compatibility improvements + +## [3.0.0] - 2022-12-14 + +### Added +- 3MF file format support (read-only) +- Support for `end solid` keyword variant in ASCII STL + +## [2.17.0] - 2022-05-14 + +### Added +- Mass properties calculation with custom density + +## [2.16.0] - 2021-03-28 + +### Fixed +- Various bug fixes + +## [2.14.0] - 2021-01-31 + +### Added +- `is_convex()` method for convexity checking + +## [2.11.0] - 2020-03-25 + +### Added +- `transform()` method for 4x4 matrix transformations + +## [2.9.0] - 2018-12-17 + +### Added +- Rotation around arbitrary points (point parameter) + +## [2.7.0] - 2018-06-25 + +### Added +- Multi-file loading (`from_files()`) + +## [2.0.0] - 2016-08-20 + +### Added +- Cython speedups for ASCII I/O +- Improved test coverage + +[Unreleased]: https://github.com/WoLpH/numpy-stl/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/WoLpH/numpy-stl/compare/v3.1.0...v3.2.0 +[3.1.0]: https://github.com/WoLpH/numpy-stl/compare/v3.0.0...v3.1.0 +[3.0.0]: https://github.com/WoLpH/numpy-stl/compare/v2.17.0...v3.0.0 +[2.17.0]: https://github.com/WoLpH/numpy-stl/compare/v2.16.0...v2.17.0 +[2.16.0]: https://github.com/WoLpH/numpy-stl/compare/v2.14.0...v2.16.0 +[2.14.0]: https://github.com/WoLpH/numpy-stl/compare/v2.11.0...v2.14.0 +[2.11.0]: https://github.com/WoLpH/numpy-stl/compare/v2.9.0...v2.11.0 +[2.9.0]: https://github.com/WoLpH/numpy-stl/compare/v2.7.0...v2.9.0 +[2.7.0]: https://github.com/WoLpH/numpy-stl/compare/v2.0.0...v2.7.0 +[2.0.0]: https://github.com/WoLpH/numpy-stl/compare/v1.3.8...v2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9766a89 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,105 @@ +# Contributing to numpy-stl + +Contributions are welcome! This guide covers the modern development +workflow. + +## Quick Start + +1. Clone and install: + + ```bash + git clone https://github.com/WoLpH/numpy-stl.git + cd numpy-stl + uv sync --all-extras + ``` + +2. Install git hooks: + + ```bash + lefthook install + ``` + +3. Run tests: + + ```bash + uv run pytest + ``` + +4. Lint and format: + + ```bash + ruff check stl tests + ruff format stl tests + ``` + +5. Type check: + + ```bash + ty check + ``` + +## Development Workflow + +### Prerequisites + +- Python 3.10+ +- [uv](https://docs.astral.sh/uv/) (package manager) +- [lefthook](https://github.com/evilmartians/lefthook) (git hooks) + +### Running the Full Test Suite + +```bash +uv run pytest +``` + +This runs all tests with coverage reporting and doctest validation. + +To run a subset: + +```bash +uv run pytest tests/test_mesh.py -x +``` + +### Pre-commit Hooks + +Lefthook runs these checks in parallel on every commit: + +- `ruff check` -- linting +- `ruff format` -- code formatting (auto-fixes staged) +- `ty check` -- type checking + +If a hook fails, fix the issue and re-commit. + +### Code Style + +- **Formatter**: ruff (79-character line length) +- **Quotes**: single quotes for all strings, including docstrings +- **Docstrings**: Google-style (`Args:`, `Returns:`, `Raises:`) +- **Type hints**: required on all functions and methods + +### Building Documentation + +```bash +uv sync --extra docs +cd docs +uv run sphinx-build -b html . _build/html +``` + +Open `docs/_build/html/index.html` to preview. + +## Pull Request Guidelines + +1. The PR should include tests for new functionality +2. All CI checks must pass (tests, lint, type checking) +3. The PR should work for Python 3.10, 3.11, 3.12, and 3.13 + +## Reporting Bugs + +File issues at https://github.com/WoLpH/numpy-stl/issues. + +Include: + +- Your OS and Python version +- Steps to reproduce +- Expected vs actual behavior +- If applicable, the STL file that triggers the issue diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 8145b5f..0000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,126 +0,0 @@ -============ -Contributing -============ - -Contributions are welcome, and they are greatly appreciated! Every -little bit helps, and credit will always be given. - -You can contribute in many ways: - -Types of Contributions ----------------------- - -Report Bugs -~~~~~~~~~~~ - -Report bugs at https://github.com/WoLpH/numpy-stl/issues. - -If you are reporting a bug, please include: - -* Your operating system name and version. -* Any details about your local setup that might be helpful in troubleshooting. -* Detailed steps to reproduce the bug. - -Fix Bugs -~~~~~~~~ - -Look through the GitHub issues for bugs. Anything tagged with "bug" -is open to whoever wants to implement it. - -Implement Features -~~~~~~~~~~~~~~~~~~ - -Look through the GitHub issues for features. Anything tagged with "feature" -is open to whoever wants to implement it. - -Write Documentation -~~~~~~~~~~~~~~~~~~~ - -numpy-stl could always use more documentation, whether as part of the -official numpy-stl docs, in docstrings, or even on the web in blog posts, -articles, and such. - -Submit Feedback -~~~~~~~~~~~~~~~ - -The best way to send feedback is to file an issue at https://github.com/WoLpH/numpy-stl/issues. - -If you are proposing a feature: - -* Explain in detail how it would work. -* Keep the scope as narrow as possible, to make it easier to implement. -* Remember that this is a volunteer-driven project, and that contributions - are welcome :) - -Get Started! ------------- - -Ready to contribute? Here's how to set up `numpy-stl` for local development. - -1. Fork the `numpy-stl` repo on GitHub. -2. Clone your fork locally:: - - $ git clone --branch develop git@github.com:your_name_here/numpy-stl.git numpy_stl - -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: - - $ mkvirtualenv numpy_stl - $ cd numpy_stl/ - $ pip install -e . - -4. Create a branch for local development with `git-flow-avh`_:: - - $ git-flow feature start name-of-your-bugfix-or-feature - - Or without git-flow: - - $ git checkout -b feature/name-of-your-bugfix-or-feature - - Now you can make your changes locally. - -5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: - - $ flake8 --ignore=W391 stl tests - $ py.test - $ tox - - To get flake8 and tox, just pip install them into your virtualenv using the requirements file. - - $ pip install -r tests/requirements.txt - -6. Commit your changes and push your branch to GitHub with `git-flow-avh`_:: - - $ git add . - $ git commit -m "Your detailed description of your changes." - $ git-flow feature publish - - Or without git-flow: - - $ git add . - $ git commit -m "Your detailed description of your changes." - $ git push -u origin feature/name-of-your-bugfix-or-feature - -7. Submit a pull request through the GitHub website. - -Pull Request Guidelines ------------------------ - -Before you submit a pull request, check that it meets these guidelines: - -1. The pull request should include tests. -2. If the pull request adds functionality, the docs should be updated. Put - your new functionality into a function with a docstring, and add the - feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.3, 3.4 and 3.5. Check - https://travis-ci.org/WoLpH/numpy-stl/pull_requests - and make sure that the tests pass for all supported Python versions. - -Tips ----- - -To run a subset of tests:: - - $ py.test tests/some_test.py - -.. _git-flow-avh: https://github.com/petervanderdoes/gitflow - diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 0c592e2..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,16 +0,0 @@ -# Include all documentation files -include-recursive *.rst -include stl/*.pyx -include stl/*.c -include LICENSE - -# Include docs and tests -graft tests -graft docs - -# Skip compiled python files -global-exclude *.py[co] - -# Remove all build directories -prune docs/_build - diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce7fae5 --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +# numpy-stl + +[![CI](https://github.com/WoLpH/numpy-stl/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/WoLpH/numpy-stl/actions/workflows/ci.yml) +[![PyPI](https://img.shields.io/pypi/v/numpy-stl)](https://pypi.org/project/numpy-stl/) +[![Python](https://img.shields.io/pypi/pyversions/numpy-stl)](https://pypi.org/project/numpy-stl/) +[![Documentation](https://readthedocs.org/projects/numpy-stl/badge/?version=latest)](https://numpy-stl.readthedocs.io/) +[![License](https://img.shields.io/pypi/l/numpy-stl)](https://github.com/WoLpH/numpy-stl/blob/develop/LICENSE) + +A fast library for reading, writing, and modifying STL files, powered +by NumPy. All mesh operations use vectorized array operations for +speed. + +*[Stanford Dragon](http://graphics.stanford.edu/data/3Dscanrep/) — 871,414 triangles loaded in 0.63s, rendered with [matplotlib](https://matplotlib.org/)* + +[![Stanford Dragon rendered with matplotlib](docs/images/dragon_render.png)](#plotting-with-matplotlib) + +## Quick Start + +```bash +pip install numpy-stl +``` + +```python +from stl import mesh + +# Load an STL file (auto-detects binary/ASCII) +your_mesh = mesh.Mesh.from_file('model.stl') + +# Inspect +print(f'{len(your_mesh)} triangles') +print(f'Bounding box: {your_mesh.min_} to {your_mesh.max_}') + +# Save +your_mesh.save('output.stl') +``` + +## Features + +- **Read and write** binary and ASCII STL files +- **Read 3MF** files (experimental, read-only) +- **Mesh operations**: rotate, translate, transform (4x4 matrix) +- **Properties**: surface area, volume, center of gravity, inertia + tensor, convexity +- **Combine** multiple meshes by concatenating data arrays +- **CLI tools**: `stl`, `stl2ascii`, `stl2bin` for format conversion +- **Fast**: all operations backed by NumPy vectorized math + +## Performance / Optional Speedups + +numpy-stl is fast out of the box. For even faster ASCII STL I/O, +install the optional Cython speedups: + +```bash +pip install numpy-stl[fast] +``` + +This installs the [`speedups`](https://github.com/wolph/speedups/) +package, a compiled C extension for ASCII parsing. The library works +identically without it -- pure Python is the default. + +### Benchmark + +ASCII STL read performance — ~5x faster with the +[speedups](https://github.com/wolph/speedups/) C extension, +consistent across data sizes (median of 5 runs): + +![ASCII STL Read Performance](docs/images/benchmark_chart.png) + +| Facets | Pure Python | Speedups | Factor | +|----------:|-------------:|-----------:|-------:| +| 10,000 | 36 ms | 7 ms | 5.1x | +| 100,000 | 0.36 s | 73 ms | 4.9x | +| 871,414 | 3.10 s | 0.59 s | 5.2x | +| 1,000,000 | 3.60 s | 0.73 s | 4.9x | + +> **Note:** Results will vary by hardware. Run the benchmark yourself: +> `python benchmarks/benchmark_ascii_read.py` + +## Usage Examples + +### Creating a Mesh from Scratch + +```python +import numpy as np +from stl import mesh + +# Define vertices and faces of a cube +vertices = np.array([ + [-1, -1, -1], [+1, -1, -1], [+1, +1, -1], [-1, +1, -1], + [-1, -1, +1], [+1, -1, +1], [+1, +1, +1], [-1, +1, +1], +]) +faces = np.array([ + [0, 3, 1], [1, 3, 2], [0, 4, 7], [0, 7, 3], + [4, 5, 6], [4, 6, 7], [5, 1, 2], [5, 2, 6], + [2, 3, 6], [3, 7, 6], [0, 1, 5], [0, 5, 4], +]) + +cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype)) +for i, f in enumerate(faces): + for j in range(3): + cube.vectors[i][j] = vertices[f[j], :] + +cube.save('cube.stl') +``` + +### Rotating and Translating + +```python +import math +from stl import mesh + +m = mesh.Mesh.from_file('model.stl') +m.rotate([0, 0, 1], math.radians(90)) +m.translate([10, 0, 0]) +m.save('transformed.stl') +``` + +### Mass Properties + +```python +from stl import mesh + +m = mesh.Mesh.from_file('closed_model.stl') +volume, cog, inertia = m.get_mass_properties() +print(f'Volume: {volume:.4f}') +print(f'Center of gravity: {cog}') +``` + +### Combining Meshes + +```python +import numpy as np +from stl import mesh + +m1 = mesh.Mesh.from_file('part1.stl') +m2 = mesh.Mesh.from_file('part2.stl') +combined = mesh.Mesh(np.concatenate([m1.data, m2.data])) +combined.save('combined.stl') +``` + +### Plotting with Matplotlib + +```python +import math +from stl import mesh +from mpl_toolkits import mplot3d +from matplotlib import pyplot + +figure = pyplot.figure(figsize=(8, 6)) +axes = figure.add_subplot(projection='3d') + +dragon = mesh.Mesh.from_ply_file('dragon_vrip.ply') +dragon.rotate([1, 0, 0], math.radians(-90)) + +axes.add_collection3d( + mplot3d.art3d.Poly3DCollection(dragon.vectors) +) + +scale = dragon.points.flatten() +axes.auto_scale_xyz(scale, scale, scale) +pyplot.show() +``` + +## CLI Tools + +```bash +# Convert ASCII to binary +stl2bin input.stl output.stl + +# Convert binary to ASCII +stl2ascii input.stl output.stl + +# Auto-detect and convert +stl input.stl output.stl +``` + +## Documentation + +Full documentation is available at +[numpy-stl.readthedocs.io](https://numpy-stl.readthedocs.io/). + +## Contributing + +Contributions are welcome! See +[CONTRIBUTING.md](https://github.com/WoLpH/numpy-stl/blob/develop/CONTRIBUTING.md) +for the development setup guide. + +## Links + +- [Source code](https://github.com/WoLpH/numpy-stl) +- [PyPI](https://pypi.org/project/numpy-stl/) +- [Bug reports](https://github.com/WoLpH/numpy-stl/issues) +- [Documentation](https://numpy-stl.readthedocs.io/) +- [Changelog](https://github.com/WoLpH/numpy-stl/blob/develop/CHANGELOG.md) + +## License + +BSD-3-Clause diff --git a/README.rst b/README.rst deleted file mode 100644 index 9b8228b..0000000 --- a/README.rst +++ /dev/null @@ -1,485 +0,0 @@ -numpy-stl -============================================================================== - -.. image:: https://github.com/WoLpH/numpy-stl/actions/workflows/main.yml/badge.svg?branch=master - :alt: numpy-stl test status - :target: https://github.com/WoLpH/numpy-stl/actions/workflows/main.yml - -.. image:: https://ci.appveyor.com/api/projects/status/cbv7ak2i59wf3lpj?svg=true - :alt: numpy-stl test status - :target: https://ci.appveyor.com/project/WoLpH/numpy-stl - -.. image:: https://badge.fury.io/py/numpy-stl.svg - :alt: numpy-stl Pypi version - :target: https://pypi.python.org/pypi/numpy-stl - -.. image:: https://coveralls.io/repos/WoLpH/numpy-stl/badge.svg?branch=master - :alt: numpy-stl code coverage - :target: https://coveralls.io/r/WoLpH/numpy-stl?branch=master - -.. image:: https://img.shields.io/pypi/pyversions/numpy-stl.svg - -Simple library to make working with STL files (and 3D objects in general) fast -and easy. - -Due to all operations heavily relying on `numpy` this is one of the fastest -STL editing libraries for Python available. - -Security contact information ------------------------------------------------------------------------------- - -To report a security vulnerability, please use the -`Tidelift security contact `_. -Tidelift will coordinate the fix and disclosure. - -Issues ------- - -If you encounter any issues, make sure you report them `here `_. Be sure to search for existing issues however. Many issues have been covered before. -While this project uses `numpy` as it's main dependency, it is not in any way affiliated to the `numpy` project or the NumFocus organisation. - -Links ------ - - - The source: https://github.com/WoLpH/numpy-stl - - Project page: https://pypi.python.org/pypi/numpy-stl - - Reporting bugs: https://github.com/WoLpH/numpy-stl/issues - - Documentation: http://numpy-stl.readthedocs.org/en/latest/ - - My blog: https://wol.ph/ - -Requirements for installing: ------------------------------------------------------------------------------- - - - `numpy`_ any recent version - - `python-utils`_ version 1.6 or greater - -Installation: ------------------------------------------------------------------------------- - -`pip install numpy-stl` - -Initial usage: ------------------------------------------------------------------------------- - -After installing the package, you should be able to run the following commands -similar to how you can run `pip`. - -.. code-block:: shell - - $ stl2bin your_ascii_stl_file.stl new_binary_stl_file.stl - $ stl2ascii your_binary_stl_file.stl new_ascii_stl_file.stl - $ stl your_ascii_stl_file.stl new_binary_stl_file.stl - -Contributing: ------------------------------------------------------------------------------- - -Contributions are always welcome. Please view the guidelines to get started: -https://github.com/WoLpH/numpy-stl/blob/develop/CONTRIBUTING.rst - -Quickstart ------------------------------------------------------------------------------- - -.. code-block:: python - - import numpy - from stl import mesh - - # Using an existing stl file: - your_mesh = mesh.Mesh.from_file('some_file.stl') - - # Or creating a new mesh (make sure not to overwrite the `mesh` import by - # naming it `mesh`): - VERTICE_COUNT = 100 - data = numpy.zeros(VERTICE_COUNT, dtype=mesh.Mesh.dtype) - your_mesh = mesh.Mesh(data, remove_empty_areas=False) - - # The mesh normals (calculated automatically) - your_mesh.normals - # The mesh vectors - your_mesh.v0, your_mesh.v1, your_mesh.v2 - # Accessing individual points (concatenation of v0, v1 and v2 in triplets) - assert (your_mesh.points[0][0:3] == your_mesh.v0[0]).all() - assert (your_mesh.points[0][3:6] == your_mesh.v1[0]).all() - assert (your_mesh.points[0][6:9] == your_mesh.v2[0]).all() - assert (your_mesh.points[1][0:3] == your_mesh.v0[1]).all() - - your_mesh.save('new_stl_file.stl') - -Plotting using `matplotlib`_ is equally easy: ------------------------------------------------------------------------------- - -.. code-block:: python - - from stl import mesh - from mpl_toolkits import mplot3d - from matplotlib import pyplot - - # Create a new plot - figure = pyplot.figure() - axes = figure.add_subplot(projection='3d') - - # Load the STL files and add the vectors to the plot - your_mesh = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl') - axes.add_collection3d(mplot3d.art3d.Poly3DCollection(your_mesh.vectors)) - - # Auto scale to the mesh size - scale = your_mesh.points.flatten() - axes.auto_scale_xyz(scale, scale, scale) - - # Show the plot to the screen - pyplot.show() - -.. _numpy: http://numpy.org/ -.. _matplotlib: http://matplotlib.org/ -.. _python-utils: https://github.com/WoLpH/python-utils - -Experimental support for reading 3MF files ------------------------------------------------------------------------------- - -.. code-block:: python - - import pathlib - import stl - - path = pathlib.Path('tests/3mf/Moon.3mf') - - # Load the 3MF file - for m in stl.Mesh.from_3mf_file(path): - # Do something with the mesh - print('mesh', m) - -Note that this is still experimental and may not work for all 3MF files. -Additionally it only allows reading 3mf files, not writing them. - -Modifying Mesh objects ------------------------------------------------------------------------------- - -.. code-block:: python - - from stl import mesh - import math - import numpy - - # Create 3 faces of a cube - data = numpy.zeros(6, dtype=mesh.Mesh.dtype) - - # Top of the cube - data['vectors'][0] = numpy.array([[0, 1, 1], - [1, 0, 1], - [0, 0, 1]]) - data['vectors'][1] = numpy.array([[1, 0, 1], - [0, 1, 1], - [1, 1, 1]]) - # Front face - data['vectors'][2] = numpy.array([[1, 0, 0], - [1, 0, 1], - [1, 1, 0]]) - data['vectors'][3] = numpy.array([[1, 1, 1], - [1, 0, 1], - [1, 1, 0]]) - # Left face - data['vectors'][4] = numpy.array([[0, 0, 0], - [1, 0, 0], - [1, 0, 1]]) - data['vectors'][5] = numpy.array([[0, 0, 0], - [0, 0, 1], - [1, 0, 1]]) - - # Since the cube faces are from 0 to 1 we can move it to the middle by - # substracting .5 - data['vectors'] -= .5 - - # Generate 4 different meshes so we can rotate them later - meshes = [mesh.Mesh(data.copy()) for _ in range(4)] - - # Rotate 90 degrees over the Y axis - meshes[0].rotate([0.0, 0.5, 0.0], math.radians(90)) - - # Translate 2 points over the X axis - meshes[1].x += 2 - - # Rotate 90 degrees over the X axis - meshes[2].rotate([0.5, 0.0, 0.0], math.radians(90)) - # Translate 2 points over the X and Y points - meshes[2].x += 2 - meshes[2].y += 2 - - # Rotate 90 degrees over the X and Y axis - meshes[3].rotate([0.5, 0.0, 0.0], math.radians(90)) - meshes[3].rotate([0.0, 0.5, 0.0], math.radians(90)) - # Translate 2 points over the Y axis - meshes[3].y += 2 - - - # Optionally render the rotated cube faces - from matplotlib import pyplot - from mpl_toolkits import mplot3d - - # Create a new plot - figure = pyplot.figure() - axes = figure.add_subplot(projection='3d') - - # Render the cube faces - for m in meshes: - axes.add_collection3d(mplot3d.art3d.Poly3DCollection(m.vectors)) - - # Auto scale to the mesh size - scale = numpy.concatenate([m.points for m in meshes]).flatten() - axes.auto_scale_xyz(scale, scale, scale) - - # Show the plot to the screen - pyplot.show() - -Extending Mesh objects ------------------------------------------------------------------------------- - -.. code-block:: python - - from stl import mesh - import math - import numpy - - # Create 3 faces of a cube - data = numpy.zeros(6, dtype=mesh.Mesh.dtype) - - # Top of the cube - data['vectors'][0] = numpy.array([[0, 1, 1], - [1, 0, 1], - [0, 0, 1]]) - data['vectors'][1] = numpy.array([[1, 0, 1], - [0, 1, 1], - [1, 1, 1]]) - # Front face - data['vectors'][2] = numpy.array([[1, 0, 0], - [1, 0, 1], - [1, 1, 0]]) - data['vectors'][3] = numpy.array([[1, 1, 1], - [1, 0, 1], - [1, 1, 0]]) - # Left face - data['vectors'][4] = numpy.array([[0, 0, 0], - [1, 0, 0], - [1, 0, 1]]) - data['vectors'][5] = numpy.array([[0, 0, 0], - [0, 0, 1], - [1, 0, 1]]) - - # Since the cube faces are from 0 to 1 we can move it to the middle by - # substracting .5 - data['vectors'] -= .5 - - cube_back = mesh.Mesh(data.copy()) - cube_front = mesh.Mesh(data.copy()) - - # Rotate 90 degrees over the X axis followed by the Y axis followed by the - # X axis - cube_back.rotate([0.5, 0.0, 0.0], math.radians(90)) - cube_back.rotate([0.0, 0.5, 0.0], math.radians(90)) - cube_back.rotate([0.5, 0.0, 0.0], math.radians(90)) - - cube = mesh.Mesh(numpy.concatenate([ - cube_back.data.copy(), - cube_front.data.copy(), - ])) - - # Optionally render the rotated cube faces - from matplotlib import pyplot - from mpl_toolkits import mplot3d - - # Create a new plot - figure = pyplot.figure() - axes = figure.add_subplot(projection='3d') - - # Render the cube - axes.add_collection3d(mplot3d.art3d.Poly3DCollection(cube.vectors)) - - # Auto scale to the mesh size - scale = cube_back.points.flatten() - axes.auto_scale_xyz(scale, scale, scale) - - # Show the plot to the screen - pyplot.show() - -Creating a single triangle ----------------------------------- - -.. code-block:: python - - import numpy - from stl import mesh - - # A unit triangle - tri_vectors = [[0,0,0],[0,1,0],[0,0,1]] - - # Create the vector data. It’s a numpy structured array with N entries, where N is the number of triangles (here N=1), and each entry is in the format ('normals','vectors','attr') - data = numpy.array([( - 0, # Set 'normals' to zero, and the mesh class will automatically calculate them at initialization - tri_vectors, # 'vectors' - 0 # 'attr' - )], dtype = mesh.Mesh.dtype) # The structure defined by the mesh class (N x ('normals','vectors','attr')) - - # Create the mesh object from the structured array - tri_mesh = mesh.Mesh(data) - - # Optionally make a plot for fun - # Load the plot tools - from matplotlib import pyplot - from mpl_toolkits import mplot3d - - # Create a new plot - figure = pyplot.figure() - axes = figure.add_subplot(projection='3d') - - # Add mesh to plot - axes.add_collection3d(mplot3d.art3d.Poly3DCollection(tri_mesh.vectors)) # Just need the 'vectors' attribute for display - -Creating Mesh objects from a list of vertices and faces ------------------------------------------------------------------------------- - -.. code-block:: python - - import numpy as np - from stl import mesh - - # Define the 8 vertices of the cube - vertices = np.array([\ - [-1, -1, -1], - [+1, -1, -1], - [+1, +1, -1], - [-1, +1, -1], - [-1, -1, +1], - [+1, -1, +1], - [+1, +1, +1], - [-1, +1, +1]]) - # Define the 12 triangles composing the cube - faces = np.array([\ - [0,3,1], - [1,3,2], - [0,4,7], - [0,7,3], - [4,5,6], - [4,6,7], - [5,1,2], - [5,2,6], - [2,3,6], - [3,7,6], - [0,1,5], - [0,5,4]]) - - # Create the mesh - cube = mesh.Mesh(np.zeros(faces.shape[0], dtype=mesh.Mesh.dtype)) - for i, f in enumerate(faces): - for j in range(3): - cube.vectors[i][j] = vertices[f[j],:] - - # Write the mesh to file "cube.stl" - cube.save('cube.stl') - - -Evaluating Mesh properties (Volume, Center of gravity, Inertia, Convexity) ------------------------------------------------------------------------------- - -.. code-block:: python - - import numpy as np - from stl import mesh - - # Using an existing closed stl file: - your_mesh = mesh.Mesh.from_file('some_file.stl') - - volume, cog, inertia = your_mesh.get_mass_properties() - print("Volume = {0}".format(volume)) - print("Position of the center of gravity (COG) = {0}".format(cog)) - print("Inertia matrix at expressed at the COG = {0}".format(inertia[0,:])) - print(" {0}".format(inertia[1,:])) - print(" {0}".format(inertia[2,:])) - print("Your mesh is convex: {0}".format(your_mesh.is_convex())) -Combining multiple STL files ------------------------------------------------------------------------------- - -.. code-block:: python - - import math - import stl - from stl import mesh - import numpy - - - # find the max dimensions, so we can know the bounding box, getting the height, - # width, length (because these are the step size)... - def find_mins_maxs(obj): - minx = obj.x.min() - maxx = obj.x.max() - miny = obj.y.min() - maxy = obj.y.max() - minz = obj.z.min() - maxz = obj.z.max() - return minx, maxx, miny, maxy, minz, maxz - - - def translate(_solid, step, padding, multiplier, axis): - if 'x' == axis: - items = 0, 3, 6 - elif 'y' == axis: - items = 1, 4, 7 - elif 'z' == axis: - items = 2, 5, 8 - else: - raise RuntimeError('Unknown axis %r, expected x, y or z' % axis) - - # _solid.points.shape == [:, ((x, y, z), (x, y, z), (x, y, z))] - _solid.points[:, items] += (step * multiplier) + (padding * multiplier) - - - def copy_obj(obj, dims, num_rows, num_cols, num_layers): - w, l, h = dims - copies = [] - for layer in range(num_layers): - for row in range(num_rows): - for col in range(num_cols): - # skip the position where original being copied is - if row == 0 and col == 0 and layer == 0: - continue - _copy = mesh.Mesh(obj.data.copy()) - # pad the space between objects by 10% of the dimension being - # translated - if col != 0: - translate(_copy, w, w / 10., col, 'x') - if row != 0: - translate(_copy, l, l / 10., row, 'y') - if layer != 0: - translate(_copy, h, h / 10., layer, 'z') - copies.append(_copy) - return copies - - # Using an existing stl file: - main_body = mesh.Mesh.from_file('ball_and_socket_simplified_-_main_body.stl') - - # rotate along Y - main_body.rotate([0.0, 0.5, 0.0], math.radians(90)) - - minx, maxx, miny, maxy, minz, maxz = find_mins_maxs(main_body) - w1 = maxx - minx - l1 = maxy - miny - h1 = maxz - minz - copies = copy_obj(main_body, (w1, l1, h1), 2, 2, 1) - - # I wanted to add another related STL to the final STL - twist_lock = mesh.Mesh.from_file('ball_and_socket_simplified_-_twist_lock.stl') - minx, maxx, miny, maxy, minz, maxz = find_mins_maxs(twist_lock) - w2 = maxx - minx - l2 = maxy - miny - h2 = maxz - minz - translate(twist_lock, w1, w1 / 10., 3, 'x') - copies2 = copy_obj(twist_lock, (w2, l2, h2), 2, 2, 1) - combined = mesh.Mesh(numpy.concatenate([main_body.data, twist_lock.data] + - [copy.data for copy in copies] + - [copy.data for copy in copies2])) - - combined.save('combined.stl', mode=stl.Mode.ASCII) # save as ASCII - -Known limitations ------------------------------------------------------------------------------- - - - When speedups are enabled the STL name is automatically converted to - lowercase. diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 6586d95..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,42 +0,0 @@ -image: - - Visual Studio 2019 - -environment: - matrix: - - TOXENV: py38 - - TOXENV: py39 - - TOXENV: py310 - -install: - # Download setup scripts and unzip - # - ps: "wget https://github.com/cloudify-cosmo/appveyor-utils/archive/master.zip -OutFile ./master.zip" - # - "7z e master.zip */appveyor/* -oappveyor" - - # Install Python (from the official .msi of http://python.org) and pip when - # not already installed. - # - "powershell ./appveyor/install.ps1" - - # Prepend newly installed Python to the PATH of this build (this cannot be - # done from inside the powershell script as it would require to restart - # the parent CMD process). - # - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" - - # Check that we have the expected version and architecture for Python - - py --version - - py -c "import struct; print(struct.calcsize('P') * 8)" - -build: false # Not a C# project, build stuff at the test step instead. - -before_test: - - py -m pip install tox numpy cython wheel setuptools - -test_script: - - "py -m tox -e %TOXENV%" - -after_test: - - py setup.py build_ext --inplace - - py setup.py sdist bdist_wheel - - ps: "ls dist" - -artifacts: - - path: dist\* diff --git a/benchmarks/benchmark_ascii_read.py b/benchmarks/benchmark_ascii_read.py new file mode 100644 index 0000000..4174560 --- /dev/null +++ b/benchmarks/benchmark_ascii_read.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python +"""Benchmark ASCII STL read with and without speedups. + +Downloads the Stanford Dragon PLY model on first run, +converts to ASCII STL, then times reads. + +Usage: + python benchmarks/benchmark_ascii_read.py + python benchmarks/benchmark_ascii_read.py --iterations 10 + python benchmarks/benchmark_ascii_read.py --render +""" + +from __future__ import annotations + +import argparse +import gzip +import pathlib +import statistics +import sys +import time +import urllib.error +import urllib.request + +# Ensure the package is importable from repo root. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +from stl import Mode, mesh # noqa: E402 + +CACHE_DIR = pathlib.Path(__file__).parent / '.cache' +DRAGON_URLS = [ + # GitHub mirror (reliable) + 'https://raw.githubusercontent.com/' + 'hughsk/stanford-dragon/master/models/' + 'dragon_vrip.ply.gz', + # Stanford original (may be offline) + 'http://graphics.stanford.edu/pub/3Dscanrep/' + 'dragon/dragon_recon/dragon_vrip.ply.gz', +] +DRAGON_PLY = CACHE_DIR / 'dragon_vrip.ply' +DRAGON_ASCII_STL = CACHE_DIR / 'dragon_ascii.stl' + + +def download_dragon() -> pathlib.Path: + """Download and decompress the Stanford Dragon.""" + if DRAGON_PLY.exists(): + return DRAGON_PLY + + CACHE_DIR.mkdir(parents=True, exist_ok=True) + gz_path = CACHE_DIR / 'dragon_vrip.ply.gz' + + for url in DRAGON_URLS: + print(f'Downloading Stanford Dragon from {url}...') + try: + urllib.request.urlretrieve(url, gz_path) + break + except urllib.error.URLError: + print(' Failed, trying next URL...') + else: + raise RuntimeError('Could not download Dragon from any URL') + + print('Decompressing...') + with gzip.open(gz_path, 'rb') as f_in, open(DRAGON_PLY, 'wb') as f_out: + f_out.write(f_in.read()) + + gz_path.unlink() + print(f'Saved to {DRAGON_PLY}') + return DRAGON_PLY + + +def convert_to_ascii_stl() -> pathlib.Path: + """Convert the Dragon PLY to ASCII STL.""" + if DRAGON_ASCII_STL.exists(): + return DRAGON_ASCII_STL + + ply_path = download_dragon() + print(f'Loading PLY from {ply_path}...') + dragon = mesh.Mesh.from_ply_file(str(ply_path)) + print(f'Loaded {len(dragon.data)} triangles, saving as ASCII STL...') + dragon.save(str(DRAGON_ASCII_STL), mode=Mode.ASCII) + print(f'Saved to {DRAGON_ASCII_STL}') + return DRAGON_ASCII_STL + + +def benchmark_read( + stl_path: pathlib.Path, + speedups: bool, + iterations: int, + warmup: int = 1, +) -> float: + """Time ASCII STL reads, return median seconds.""" + for _ in range(warmup): + mesh.Mesh.from_file(str(stl_path), speedups=speedups) + + times: list[float] = [] + for _ in range(iterations): + start = time.perf_counter() + mesh.Mesh.from_file(str(stl_path), speedups=speedups) + elapsed = time.perf_counter() - start + times.append(elapsed) + + return statistics.median(times) + + +def render_dragon(output_path: pathlib.Path) -> None: + """Render the Dragon mesh to a PNG image.""" + try: + import math + + import matplotlib as mpl + + mpl.use('Agg') + from PIL import Image + from matplotlib import pyplot as plt + from mpl_toolkits import mplot3d # noqa: F401 + except ImportError: + print('matplotlib/Pillow not installed, skipping render') + return + + ply_path = download_dragon() + dragon = mesh.Mesh.from_ply_file(str(ply_path)) + + # Rotate so the dragon stands upright (PLY has Y-up, + # matplotlib uses Z-up) + dragon.rotate([1, 0, 0], math.radians(-90)) + + figure = plt.figure(figsize=(10, 7)) + axes = figure.add_subplot(projection='3d') + axes.add_collection3d( + mplot3d.art3d.Poly3DCollection( + dragon.vectors, + edgecolor='none', + facecolor='#4a90d9', + alpha=0.8, + ) + ) + + # Tight limits matching the actual model extents + mins = dragon.min_ + maxs = dragon.max_ + rng = maxs - mins + pad = 0.02 + axes.set_xlim( + mins[0] - rng[0] * pad, + maxs[0] + rng[0] * pad, + ) + axes.set_ylim( + mins[1] - rng[1] * pad, + maxs[1] + rng[1] * pad, + ) + axes.set_zlim( + mins[2] - rng[2] * pad, + maxs[2] + rng[2] * pad, + ) + axes.set_box_aspect(rng / rng.max()) + axes.view_init(elev=15, azim=-120) + + figure.subplots_adjust(top=1.0, bottom=0.0, left=0.0, right=1.0) + + import tempfile + + tmp = pathlib.Path(tempfile.mktemp(suffix='.png')) + plt.savefig( + tmp, + dpi=150, + bbox_inches='tight', + facecolor='white', + pad_inches=0.0, + ) + plt.close() + + # Auto-crop whitespace (threshold catches light-gray + # grid lines that are near-white) + img = Image.open(tmp).convert('RGB') + import numpy as np # noqa: ICN001 + + arr = np.array(img) + not_white = np.any(arr < 245, axis=2) + rows = np.any(not_white, axis=1) + cols = np.any(not_white, axis=0) + rr = np.where(rows)[0] + cc = np.where(cols)[0] + if len(rr) and len(cc): + p = 4 + img = img.crop( + ( + max(0, int(cc[0]) - p), + max(0, int(rr[0]) - p), + min(img.width, int(cc[-1]) + p), + min(img.height, int(rr[-1]) + p), + ) + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(output_path) + tmp.unlink(missing_ok=True) + print(f'Render saved to {output_path}') + + +def main() -> None: + parser = argparse.ArgumentParser(description='Benchmark ASCII STL read') + parser.add_argument( + '--iterations', + type=int, + default=5, + help='Number of timed iterations (default: 5)', + ) + parser.add_argument( + '--render', + action='store_true', + help='Also render a Dragon PNG image', + ) + parser.add_argument( + '--render-output', + type=str, + default='docs/images/dragon_render.png', + help='Output path for render image', + ) + args = parser.parse_args() + + stl_path = convert_to_ascii_stl() + + dragon = mesh.Mesh.from_file(str(stl_path)) + tri_count = len(dragon.data) + + print(f'\nBenchmarking ASCII STL read ({tri_count:,} triangles)') + print(f'Iterations: {args.iterations}') + print() + + time_pure = benchmark_read( + stl_path, + speedups=False, + iterations=args.iterations, + ) + + time_fast = benchmark_read( + stl_path, + speedups=True, + iterations=args.iterations, + ) + + factor = time_pure / time_fast if time_fast > 0 else 0 + + print( + f'{"Model":<20} {"Triangles":>12} ' + f'{"Pure Python":>14} {"Speedups":>12} ' + f'{"Factor":>8}' + ) + print('-' * 70) + print( + f'{"Stanford Dragon":<20} {tri_count:>12,} ' + f'{time_pure:>13.2f}s {time_fast:>11.2f}s ' + f'{factor:>7.1f}x' + ) + + if args.render: + print() + render_dragon(pathlib.Path(args.render_output)) + + +if __name__ == '__main__': + main() diff --git a/docs/_theme/LICENSE b/docs/_theme/LICENSE deleted file mode 100644 index f258ba0..0000000 --- a/docs/_theme/LICENSE +++ /dev/null @@ -1,46 +0,0 @@ -Modifications: - -Copyright (c) 2012 Rick van Hattem. - - -Original Projects: - -Copyright (c) 2010 Kenneth Reitz. -Copyright (c) 2010 by Armin Ronacher. - - -Some rights reserved. - -Redistribution and use in source and binary forms of the theme, with or -without modification, are permitted provided that the following conditions -are met: - -* Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - -* Redistributions in binary form must reproduce the above - copyright notice, this list of conditions and the following - disclaimer in the documentation and/or other materials provided - with the distribution. - -* The names of the contributors may not be used to endorse or - promote products derived from this software without specific - prior written permission. - -We kindly ask you to only use these themes in an unmodified manner just -for Flask and Flask-related products, not for unrelated projects. If you -like the visual style and want to use it for your own projects, please -consider making some larger changes to the themes (such as changing -font faces, sizes, colors or margins). - -THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/_theme/flask_theme_support.py b/docs/_theme/flask_theme_support.py deleted file mode 100644 index 6915638..0000000 --- a/docs/_theme/flask_theme_support.py +++ /dev/null @@ -1,89 +0,0 @@ -# flasky extensions. flasky pygments style based on tango style -from pygments.style import Style -from pygments.token import ( - Comment, - Error, - Generic, - Keyword, - Literal, - Name, - Number, - Operator, - Other, - Punctuation, - String, - Whitespace, -) - - -class FlaskyStyle(Style): - background_color = '#f8f8f8' - default_style = '' - - styles = { - # No corresponding class for the following: - # Text: "", # class: '' - Whitespace: 'underline #f8f8f8', # class: 'w' - Error: '#a40000 border:#ef2929', # class: 'err' - Other: '#000000', # class 'x' - Comment: 'italic #8f5902', # class: 'c' - Comment.Preproc: 'noitalic', # class: 'cp' - Keyword: 'bold #004461', # class: 'k' - Keyword.Constant: 'bold #004461', # class: 'kc' - Keyword.Declaration: 'bold #004461', # class: 'kd' - Keyword.Namespace: 'bold #004461', # class: 'kn' - Keyword.Pseudo: 'bold #004461', # class: 'kp' - Keyword.Reserved: 'bold #004461', # class: 'kr' - Keyword.Type: 'bold #004461', # class: 'kt' - Operator: '#582800', # class: 'o' - Operator.Word: 'bold #004461', # class: 'ow' - like keywords - Punctuation: 'bold #000000', # class: 'p' - # because special names such as Name.Class, Name.Function, etc. - # are not recognized as such later in the parsing, we choose them - # to look the same as ordinary variables. - Name: '#000000', # class: 'n' - Name.Attribute: '#c4a000', # class: 'na' - to be revised - Name.Builtin: '#004461', # class: 'nb' - Name.Builtin.Pseudo: '#3465a4', # class: 'bp' - Name.Class: '#000000', # class: 'nc' - to be revised - Name.Constant: '#000000', # class: 'no' - to be revised - Name.Decorator: '#888', # class: 'nd' - to be revised - Name.Entity: '#ce5c00', # class: 'ni' - Name.Exception: 'bold #cc0000', # class: 'ne' - Name.Function: '#000000', # class: 'nf' - Name.Property: '#000000', # class: 'py' - Name.Label: '#f57900', # class: 'nl' - Name.Namespace: '#000000', # class: 'nn' - to be revised - Name.Other: '#000000', # class: 'nx' - Name.Tag: 'bold #004461', # class: 'nt' - like a keyword - Name.Variable: '#000000', # class: 'nv' - to be revised - Name.Variable.Class: '#000000', # class: 'vc' - to be revised - Name.Variable.Global: '#000000', # class: 'vg' - to be revised - Name.Variable.Instance: '#000000', # class: 'vi' - to be revised - Number: '#990000', # class: 'm' - Literal: '#000000', # class: 'l' - Literal.Date: '#000000', # class: 'ld' - String: '#4e9a06', # class: 's' - String.Backtick: '#4e9a06', # class: 'sb' - String.Char: '#4e9a06', # class: 'sc' - String.Doc: 'italic #8f5902', # class: 'sd' - like a comment - String.Double: '#4e9a06', # class: 's2' - String.Escape: '#4e9a06', # class: 'se' - String.Heredoc: '#4e9a06', # class: 'sh' - String.Interpol: '#4e9a06', # class: 'si' - String.Other: '#4e9a06', # class: 'sx' - String.Regex: '#4e9a06', # class: 'sr' - String.Single: '#4e9a06', # class: 's1' - String.Symbol: '#4e9a06', # class: 'ss' - Generic: '#000000', # class: 'g' - Generic.Deleted: '#a40000', # class: 'gd' - Generic.Emph: 'italic #000000', # class: 'ge' - Generic.Error: '#ef2929', # class: 'gr' - Generic.Heading: 'bold #000080', # class: 'gh' - Generic.Inserted: '#00A000', # class: 'gi' - Generic.Output: '#888', # class: 'go' - Generic.Prompt: '#745334', # class: 'gp' - Generic.Strong: 'bold #000000', # class: 'gs' - Generic.Subheading: 'bold #800080', # class: 'gu' - Generic.Traceback: 'bold #a40000', # class: 'gt' - } diff --git a/docs/_theme/wolph/layout.html b/docs/_theme/wolph/layout.html deleted file mode 100644 index d7c8792..0000000 --- a/docs/_theme/wolph/layout.html +++ /dev/null @@ -1,16 +0,0 @@ -{%- extends "basic/layout.html" %} -{%- block extrahead %} - {{ super() }} - {% if theme_touch_icon %} - - {% endif %} - -{% endblock %} -{%- block relbar2 %}{% endblock %} -{%- block footer %} - -{%- endblock %} diff --git a/docs/_theme/wolph/relations.html b/docs/_theme/wolph/relations.html deleted file mode 100644 index 3bbcde8..0000000 --- a/docs/_theme/wolph/relations.html +++ /dev/null @@ -1,19 +0,0 @@ -

Related Topics

- diff --git a/docs/_theme/wolph/static/flasky.css_t b/docs/_theme/wolph/static/flasky.css_t deleted file mode 100644 index 71aae28..0000000 --- a/docs/_theme/wolph/static/flasky.css_t +++ /dev/null @@ -1,431 +0,0 @@ -/* - * flasky.css_t - * ~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. Modifications by Kenneth Reitz. - * :license: Flask Design License, see LICENSE for details. - */ - -{% set page_width = '940px' %} -{% set sidebar_width = '220px' %} - -@import url("basic.css"); - -/* -- page layout ----------------------------------------------------------- */ - -body { - font-family: 'goudy old style', 'minion pro', 'bell mt', Georgia, 'Hiragino Mincho Pro'; - font-size: 17px; - background-color: white; - color: #000; - margin: 0; - padding: 0; -} - -div.document { - width: {{ page_width }}; - margin: 30px auto 0 auto; -} - -div.documentwrapper { - float: left; - width: 100%; -} - -div.bodywrapper { - margin: 0 0 0 {{ sidebar_width }}; -} - -div.sphinxsidebar { - width: {{ sidebar_width }}; -} - -hr { - border: 1px solid #B1B4B6; -} - -div.body { - background-color: #ffffff; - color: #3E4349; - padding: 0 30px 0 30px; -} - -img.floatingflask { - padding: 0 0 10px 10px; - float: right; -} - -div.footer { - width: {{ page_width }}; - margin: 20px auto 30px auto; - font-size: 14px; - color: #888; - text-align: right; -} - -div.footer a { - color: #888; -} - -div.related { - display: none; -} - -div.sphinxsidebar a { - color: #444; - text-decoration: none; - border-bottom: 1px dotted #999; -} - -div.sphinxsidebar a:hover { - border-bottom: 1px solid #999; -} - -div.sphinxsidebar { - font-size: 14px; - line-height: 1.5; -} - -div.sphinxsidebarwrapper { - padding: 0px 10px; -} - -div.sphinxsidebarwrapper p.logo { - padding: 0 0 20px 0; - margin: 0; - text-align: center; -} - -div.sphinxsidebar h3, -div.sphinxsidebar h4 { - font-family: 'Garamond', 'Georgia', serif; - color: #555; - font-size: 24px; - font-weight: normal; - margin: 0 0 5px 0; - padding: 0; -} - -div.sphinxsidebar h4 { - font-size: 20px; -} - -div.sphinxsidebar h3 a { - color: #444; -} - -div.sphinxsidebar p.logo a, -div.sphinxsidebar h3 a, -div.sphinxsidebar p.logo a:hover, -div.sphinxsidebar h3 a:hover { - border: none; -} - -div.sphinxsidebar p { - color: #555; - margin: 10px 0; -} - -div.sphinxsidebar ul { - margin: 10px 0; - padding: 0; - color: #000; -} - -div.sphinxsidebar input[type="text"] { - width: 160px!important; -} -div.sphinxsidebar input { - border: 1px solid #ccc; - font-family: 'Georgia', serif; - font-size: 1em; -} - -/* -- body styles ----------------------------------------------------------- */ - -a { - color: #004B6B; - text-decoration: underline; -} - -a:hover { - color: #6D4100; - text-decoration: underline; -} - -div.body h1, -div.body h2, -div.body h3, -div.body h4, -div.body h5, -div.body h6 { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - margin: 30px 0px 10px 0px; - padding: 0; -} - -div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } -div.body h2 { font-size: 180%; } -div.body h3 { font-size: 150%; } -div.body h4 { font-size: 130%; } -div.body h5 { font-size: 100%; } -div.body h6 { font-size: 100%; } - -a.headerlink { - color: #ddd; - padding: 0 4px; - text-decoration: none; -} - -a.headerlink:hover { - color: #444; - background: #eaeaea; -} - -div.body p, div.body dd, div.body li { - line-height: 1.4em; -} - -div.admonition { - background: #fafafa; - margin: 20px -30px; - padding: 10px 30px; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; -} - -div.admonition tt.xref, div.admonition a tt { - border-bottom: 1px solid #fafafa; -} - -dd div.admonition { - margin-left: -60px; - padding-left: 60px; -} - -div.admonition p.admonition-title { - font-family: 'Garamond', 'Georgia', serif; - font-weight: normal; - font-size: 24px; - margin: 0 0 10px 0; - padding: 0; - line-height: 1; -} - -div.admonition p.last { - margin-bottom: 0; -} - -div.highlight { - background-color: white; -} - -dt:target, .highlight { - background: #FAF3E8; -} - -div.note { - background-color: #eee; - border: 1px solid #ccc; -} - -div.seealso { - background-color: #ffc; - border: 1px solid #ff6; -} - -div.topic { - background-color: #eee; -} - -p.admonition-title { - display: inline; -} - -p.admonition-title:after { - content: ":"; -} - -pre, tt { - font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; - font-size: 0.9em; -} - -img.screenshot { -} - -tt.descname, tt.descclassname { - font-size: 0.95em; -} - -tt.descname { - padding-right: 0.08em; -} - -img.screenshot { - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils { - border: 1px solid #888; - -moz-box-shadow: 2px 2px 4px #eee; - -webkit-box-shadow: 2px 2px 4px #eee; - box-shadow: 2px 2px 4px #eee; -} - -table.docutils td, table.docutils th { - border: 1px solid #888; - padding: 0.25em 0.7em; -} - -table.field-list, table.footnote { - border: none; - -moz-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; -} - -table.footnote { - margin: 15px 0; - width: 100%; - border: 1px solid #eee; - background: #fdfdfd; - font-size: 0.9em; -} - -table.footnote + table.footnote { - margin-top: -15px; - border-top: none; -} - -table.field-list th { - padding: 0 0.8em 0 0; -} - -table.field-list td { - padding: 0; -} - -table.footnote td.label { - width: 0px; - padding: 0.3em 0 0.3em 0.5em; -} - -table.footnote td { - padding: 0.3em 0.5em; -} - -dl { - margin: 0; - padding: 0; -} - -dl dd { - margin-left: 30px; -} - -blockquote { - margin: 0 0 0 30px; - padding: 0; -} - -ul, ol { - margin: 10px 0 10px 30px; - padding: 0; -} - -pre { - background: #eee; - padding: 7px 30px; - margin: 15px -30px; - line-height: 1.3em; -} - -dl pre, blockquote pre, li pre { - margin-left: -60px; - padding-left: 60px; -} - -dl dl pre { - margin-left: -90px; - padding-left: 90px; -} - -tt { - background-color: #ecf0f3; - color: #222; - /* padding: 1px 2px; */ -} - -tt.xref, a tt { - background-color: #FBFBFB; - border-bottom: 1px solid white; -} - -a.reference { - text-decoration: none; - border-bottom: 1px dotted #004B6B; -} - -a.reference:hover { - border-bottom: 1px solid #6D4100; -} - -a.footnote-reference { - text-decoration: none; - font-size: 0.7em; - vertical-align: top; - border-bottom: 1px dotted #004B6B; -} - -a.footnote-reference:hover { - border-bottom: 1px solid #6D4100; -} - -a:hover tt { - background: #EEE; -} - - -/* scrollbars */ - -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-button:start:decrement, -::-webkit-scrollbar-button:end:increment { - display: block; - height: 10px; -} - -::-webkit-scrollbar-button:vertical:increment { - background-color: #fff; -} - -::-webkit-scrollbar-track-piece { - background-color: #eee; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:vertical { - height: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -::-webkit-scrollbar-thumb:horizontal { - width: 50px; - background-color: #ccc; - -webkit-border-radius: 3px; -} - -/* misc. */ - -.revsys-inline { - display: none!important; -} diff --git a/docs/_theme/wolph/static/small_flask.css b/docs/_theme/wolph/static/small_flask.css deleted file mode 100644 index 1c6df30..0000000 --- a/docs/_theme/wolph/static/small_flask.css +++ /dev/null @@ -1,70 +0,0 @@ -/* - * small_flask.css_t - * ~~~~~~~~~~~~~~~~~ - * - * :copyright: Copyright 2010 by Armin Ronacher. - * :license: Flask Design License, see LICENSE for details. - */ - -body { - margin: 0; - padding: 20px 30px; -} - -div.documentwrapper { - float: none; - background: white; -} - -div.sphinxsidebar { - display: block; - float: none; - width: 102.5%; - margin: 50px -30px -20px -30px; - padding: 10px 20px; - background: #333; - color: white; -} - -div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, -div.sphinxsidebar h3 a { - color: white; -} - -div.sphinxsidebar a { - color: #aaa; -} - -div.sphinxsidebar p.logo { - display: none; -} - -div.document { - width: 100%; - margin: 0; -} - -div.related { - display: block; - margin: 0; - padding: 10px 0 20px 0; -} - -div.related ul, -div.related ul li { - margin: 0; - padding: 0; -} - -div.footer { - display: none; -} - -div.bodywrapper { - margin: 0; -} - -div.body { - min-height: 0; - padding: 0; -} diff --git a/docs/_theme/wolph/theme.conf b/docs/_theme/wolph/theme.conf deleted file mode 100644 index 307a1f0..0000000 --- a/docs/_theme/wolph/theme.conf +++ /dev/null @@ -1,7 +0,0 @@ -[theme] -inherit = basic -stylesheet = flasky.css -pygments_style = flask_theme_support.FlaskyStyle - -[options] -touch_icon = diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 0000000..7347036 --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,40 @@ +API Reference +============= + +.. toctree:: + :maxdepth: 2 + + mesh + +The primary entry point is :class:`stl.Mesh`, which inherits +from :class:`~stl.stl.BaseStl` and :class:`~stl.base.BaseMesh`. + +Enumerations +------------ + +.. autoclass:: stl.Mode + :members: + :undoc-members: + +.. autoclass:: stl.Dimension + :members: + :undoc-members: + +.. autoclass:: stl.RemoveDuplicates + :members: + :undoc-members: + +Constants +--------- + +.. data:: stl.HEADER_SIZE + + Size of the binary STL header in bytes (80). + +.. data:: stl.COUNT_SIZE + + Size of the triangle count field in bytes (4). + +.. data:: stl.MAX_COUNT + + Maximum number of triangles in a binary STL file (100,000,000). diff --git a/docs/api/mesh.rst b/docs/api/mesh.rst new file mode 100644 index 0000000..ea8860b --- /dev/null +++ b/docs/api/mesh.rst @@ -0,0 +1,15 @@ +stl.Mesh +======== + +.. autoclass:: stl.Mesh + :members: + :inherited-members: + :show-inheritance: + :exclude-members: dtype + + .. autoattribute:: dtype + :annotation: = numpy.dtype([('normals', '{metadata.__author__}' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = metadata.__version__ -# The full version, including alpha/beta/rc tags. -release = metadata.__version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# today = '' -# Else, today_fmt is used as the format for a strftime call. -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = ['_build'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as 'system message' paragraphs in the built documents. -# keep_warnings = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'wolph' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# html_theme_options = {} +# -- Theme +html_theme = 'furo' -# Add any paths that contain custom themes here, relative to this directory. -html_theme_path = ['_theme'] +# -- Napoleon (Google-style docstrings) +napoleon_google_docstring = True +napoleon_numpy_docstring = False +napoleon_use_param = True +napoleon_use_rtype = True -# The name for this set of Sphinx documents. If None, it defaults to -# ' v documentation'. -# html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named 'default.css' will overwrite the builtin 'default.css'. -# html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -# html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# html_additional_pages = {} - -# If false, no module index is generated. -# html_domain_indices = True - -# If false, no index is generated. -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# html_show_sourcelink = True - -# If true, 'Created using Sphinx' is shown in the HTML footer. Default is True. -# html_show_sphinx = True - -# If true, '(C) Copyright ...' is shown in the HTML footer. Default is True. -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. '.xhtml'). -# html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = metadata.__package_name__ + '-doc' - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - #'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples (source start -# file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ( - 'index', - f'{metadata.__package_name__}.tex', - '{} Documentation'.format( - metadata.__package_name__.replace('-', ' ').capitalize() - ), - metadata.__author__, - 'manual', - ) -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For 'manual' documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - 'index', - metadata.__package_name__, - '{} Documentation'.format( - metadata.__package_name__.replace('-', ' ').capitalize() - ), - [metadata.__author__], - 1, - ) -] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - 'index', - metadata.__package_name__, - '{} Documentation'.format( - metadata.__package_name__.replace('-', ' ').capitalize() - ), - metadata.__author__, - metadata.__package_name__, - metadata.__description__, - 'Miscellaneous', - ) -] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the 'Top' node's menu. -# texinfo_no_detailmenu = False - - -# -- Options for Epub output ---------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = metadata.__package_name__.replace('-', ' ').capitalize() -epub_author = metadata.__author__ -epub_publisher = metadata.__author__ -epub_copyright = copyright - -# The HTML theme for the epub output. Since the default themes are not -# optimized for small screen space, using the same theme for HTML and epub -# output is usually not wise. This defaults to 'epub', a theme designed to -# save visual space. epub_theme = 'epub' - -# The language of the text. It defaults to the language option -# or en if the language is not set. -# epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -# epub_scheme = '' - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -# epub_identifier = '' - -# A unique identification for the text. -# epub_uid = '' - -# A tuple containing the cover image and cover page html template filenames. -# epub_cover = () - -# A sequence of (type, uri, title) tuples for the guide element of content.opf. -# epub_guide = () - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_pre_files = [] - -# HTML files shat should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -# epub_post_files = [] - -# A list of files that should not be packed into the epub file. -epub_exclude_files = ['search.html'] - -# The depth of the table of contents in toc.ncx. -# epub_tocdepth = 3 - -# Allow duplicate toc entries. -# epub_tocdup = True - -# Choose between 'default' and 'includehidden'. -# epub_tocscope = 'default' - -# Fix unsupported image types using the PIL. -# epub_fix_images = False - -# Scale large images. -# epub_max_image_width = 0 - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# epub_show_urls = 'inline' - -# If false, no index is generated. -# epub_use_index = True - - -# Example configuration for intersphinx: refer to the Python standard library. +# -- Intersphinx intersphinx_mapping = { - 'python': ('http://docs.python.org/2', None), - 'pythonutils': ('http://python-utils.readthedocs.org/en/latest/', None), - 'numpy': ('http://docs.scipy.org/doc/numpy/', None), - 'scipy': ('http://docs.scipy.org/doc/scipy/reference/', None), - 'matplotlib': ('http://matplotlib.sourceforge.net/', None), + 'python': ('https://docs.python.org/3/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), } + +# -- Autodoc +autodoc_member_order = 'bysource' +autodoc_typehints = 'description' diff --git a/docs/getting-started/installation.rst b/docs/getting-started/installation.rst new file mode 100644 index 0000000..46dbbcd --- /dev/null +++ b/docs/getting-started/installation.rst @@ -0,0 +1,38 @@ +Installation +============ + +Basic Install +------------- + +.. code-block:: bash + + pip install numpy-stl + +This installs numpy-stl with its required dependencies (NumPy and +python-utils). + +Optional Speedups +----------------- + +For faster ASCII STL parsing, install with the ``fast`` extra: + +.. code-block:: bash + + pip install numpy-stl[fast] + +This installs the ``speedups`` package, a Cython-compiled extension +that accelerates ASCII file I/O. The library works identically +without it -- pure Python is the default. + +Requirements +------------ + +- Python 3.10 or later +- NumPy (1.x or 2.x) +- python-utils >= 3.4.5 + +Development Install +------------------- + +See the `Contributing Guide `_ +for development setup instructions. diff --git a/docs/getting-started/quickstart.rst b/docs/getting-started/quickstart.rst new file mode 100644 index 0000000..a28292c --- /dev/null +++ b/docs/getting-started/quickstart.rst @@ -0,0 +1,87 @@ +Quick Start +=========== + +This guide gets you productive with numpy-stl in under 5 minutes. + +Loading an STL File +------------------- + +.. code-block:: python + + from stl import mesh + + your_mesh = mesh.Mesh.from_file('model.stl') + print(f'{len(your_mesh)} triangles') + +Format detection (ASCII vs binary) is automatic. + +Creating a Mesh from Scratch +----------------------------- + +.. code-block:: python + + import numpy as np + from stl import mesh + + # Create a single triangle + data = np.zeros(1, dtype=mesh.Mesh.dtype) + data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + + triangle = mesh.Mesh(data) + triangle.save('triangle.stl') + +Inspecting Properties +--------------------- + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + + # Vertex data + print('First vertex of each triangle:', m.v0[:3]) + print('Bounding box:', m.min_, m.max_) + print('Surface areas:', m.areas[:3]) + +Basic Transformations +--------------------- + +.. code-block:: python + + import math + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + + # Rotate 90 degrees around the Z axis + m.rotate([0, 0, 1], math.radians(90)) + + # Translate by [10, 0, 0] + m.translate([10, 0, 0]) + + m.save('transformed.stl') + +Saving in Different Formats +---------------------------- + +.. code-block:: python + + import stl + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + + # Save as binary (default, smaller file) + m.save('output.stl') + + # Save as ASCII (human-readable) + m.save('output.stl', mode=stl.Mode.ASCII) + +Next Steps +---------- + +- :doc:`../guide/reading-writing` -- Full I/O documentation +- :doc:`../guide/mesh-operations` -- Rotations, translations, combining meshes +- :doc:`../guide/properties` -- Mass properties, convexity, surface area +- :doc:`../guide/cli` -- Command-line tools diff --git a/docs/guide/cli.rst b/docs/guide/cli.rst new file mode 100644 index 0000000..c920e0e --- /dev/null +++ b/docs/guide/cli.rst @@ -0,0 +1,56 @@ +Command-Line Tools +================== + +numpy-stl provides three CLI commands, installed automatically +with the package. + +stl +--- + +Convert between ASCII and binary STL formats: + +.. code-block:: bash + + # Auto-detect input, write binary output + stl input.stl output.stl + + # Force ASCII output + stl input.stl output.stl -a + + # Force binary output + stl input.stl output.stl -b + +stl2ascii +--------- + +Convert a binary STL file to ASCII format: + +.. code-block:: bash + + stl2ascii input.stl output.stl + +stl2bin +------- + +Convert an ASCII STL file to binary format: + +.. code-block:: bash + + stl2bin input.stl output.stl + +Options +------- + +All commands support ``-n`` to keep the normals stored in the input file +instead of recalculating them on output: + +.. code-block:: bash + + stl input.stl output.stl -n + +All commands also support ``-s`` to force the pure-Python reader/writer +and disable optional speedups: + +.. code-block:: bash + + stl input.stl output.stl -s diff --git a/docs/guide/mesh-operations.rst b/docs/guide/mesh-operations.rst new file mode 100644 index 0000000..df9d31c --- /dev/null +++ b/docs/guide/mesh-operations.rst @@ -0,0 +1,98 @@ +Mesh Operations +=============== + +Rotation +-------- + +Rotate around an arbitrary axis: + +.. code-block:: python + + import math + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + + # Rotate 90 degrees around the Y axis + m.rotate([0, 0.5, 0], math.radians(90)) + + # Rotate around an axis passing through a specific point + m.rotate([0, 0, 1], math.radians(45), point=[1, 0, 0]) + +Translation +----------- + +Translate (move) a mesh: + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + m.translate([10, 0, 5]) + +You can also modify coordinates directly: + +.. code-block:: python + + m.x += 10 # shift all X coordinates + m.y += 5 # shift all Y coordinates + +Transformation (4x4 Matrix) +---------------------------- + +Apply a full 4x4 transformation matrix: + +.. code-block:: python + + import numpy as np + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + + # Rotate 90 degrees around Z and translate by [10, 0, 5] + matrix = np.eye(4) + matrix[:3, :3] = [ + [0.0, -1.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ] + matrix[:3, 3] = [10.0, 0.0, 5.0] + m.transform(matrix) + +Combining Meshes +---------------- + +Concatenate mesh data arrays to combine meshes: + +.. code-block:: python + + import numpy as np + from stl import mesh + + m1 = mesh.Mesh.from_file('part1.stl') + m2 = mesh.Mesh.from_file('part2.stl') + + combined = mesh.Mesh(np.concatenate([m1.data, m2.data])) + combined.save('combined.stl') + +Removing Duplicates +------------------- + +Remove duplicate or degenerate triangles: + +.. code-block:: python + + from stl import mesh, base + + m = mesh.Mesh.from_file('model.stl') + + # Remove duplicate triangles (keep one copy) + data = mesh.Mesh.remove_duplicate_polygons( + m.data, + base.RemoveDuplicates.SINGLE, + ) + + # Remove zero-area triangles + data = mesh.Mesh.remove_empty_areas(data) + cleaned = mesh.Mesh(data) diff --git a/docs/guide/properties.rst b/docs/guide/properties.rst new file mode 100644 index 0000000..1ae4a77 --- /dev/null +++ b/docs/guide/properties.rst @@ -0,0 +1,87 @@ +Mesh Properties +=============== + +Surface Area +------------ + +Per-triangle areas and total surface area: + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + print('Per-triangle areas:', m.areas) + print('Total surface area:', m.areas.sum()) + +Bounding Box +------------ + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + print('Min corner:', m.min_) + print('Max corner:', m.max_) + +Normals and Unit Normals +------------------------ + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + print('Face normals:', m.normals) + print('Unit normals:', m.units) + +Mass Properties +--------------- + +Compute volume, center of gravity, and inertia tensor. +Requires a closed (watertight) mesh: + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('closed_model.stl') + volume, cog, inertia = m.get_mass_properties() + print(f'Volume: {volume}') + print(f'Center of gravity: {cog}') + print(f'Inertia tensor:\n{inertia}') + +.. warning:: + ``get_mass_properties()`` calls ``check(exact=True)`` + internally. If the mesh is not closed, a ``RuntimeError`` + is raised. Use ``is_closed()`` to check beforehand. + +Convexity +--------- + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + print('Is convex:', m.is_convex()) + +Cache Invalidation +------------------ + +Properties like ``areas``, ``centroids``, ``min_``, ``max_``, +and ``units`` are lazily computed and cached. If you modify +vertices after accessing a property, call the corresponding +``update_*`` method to refresh: + +.. code-block:: python + + from stl import mesh + + m = mesh.Mesh.from_file('model.stl') + print(m.areas) # computed and cached + + m.x += 10 # modify vertices + m.update_areas() # refresh cache + print(m.areas) # recomputed diff --git a/docs/guide/reading-writing.rst b/docs/guide/reading-writing.rst new file mode 100644 index 0000000..09ad86a --- /dev/null +++ b/docs/guide/reading-writing.rst @@ -0,0 +1,94 @@ +Reading and Writing STL Files +============================= + +numpy-stl supports both ASCII and binary STL formats. Format +detection is automatic when loading. + +Loading a Single File +--------------------- + +.. code-block:: python + + import stl + from stl import mesh + + # Auto-detect format + m = mesh.Mesh.from_file('model.stl') + + # Force a specific format + m = mesh.Mesh.from_file('model.stl', mode=stl.Mode.ASCII) + m = mesh.Mesh.from_file('binary_model.stl', mode=stl.Mode.BINARY) + +Loading from a File Handle +-------------------------- + +.. code-block:: python + + from stl import mesh + + with open('model.stl', 'rb') as fh: + m = mesh.Mesh.from_file('model.stl', fh=fh) + +Loading Multiple Solids (ASCII Only) +------------------------------------- + +An ASCII STL file can contain multiple ``solid`` blocks. +Use :meth:`~stl.mesh.Mesh.from_multi_file` to load each as +a separate Mesh: + +.. code-block:: python + + from stl import mesh + + for m in mesh.Mesh.from_multi_file('multi.stl'): + print(f'Solid: {m.name}, {len(m)} triangles') + +.. note:: + Multi-solid loading only works with ASCII STL files. + Binary STL files always contain a single solid. + +Combining Multiple Files +------------------------- + +:meth:`~stl.mesh.Mesh.from_files` merges multiple STL files +into a single mesh: + +.. code-block:: python + + from stl import mesh + + combined = mesh.Mesh.from_files(['part1.stl', 'part2.stl']) + +Reading 3MF Files +----------------- + +Experimental support for reading 3MF files (read-only): + +.. code-block:: python + + import pathlib + from stl import mesh + + for m in mesh.Mesh.from_3mf_file(pathlib.Path('model.3mf')): + print(f'{len(m)} triangles') + +Saving +------ + +.. code-block:: python + + import stl + from stl import mesh + + m = mesh.Mesh.from_file('input.stl') + + # Binary (default, compact) + m.save('output.stl') + + # ASCII (human-readable) + m.save('output.stl', mode=stl.Mode.ASCII) + +.. warning:: + The ``save`` method requires a binary file handle (``'wb'`` mode) + even when saving ASCII format. If you pass a text-mode handle, + a ``TypeError`` is raised. diff --git a/docs/guide/speedups.rst b/docs/guide/speedups.rst new file mode 100644 index 0000000..37ff031 --- /dev/null +++ b/docs/guide/speedups.rst @@ -0,0 +1,47 @@ +Performance and Speedups +======================== + +numpy-stl is fast by default because all mesh operations use +vectorized NumPy operations. For even faster ASCII file I/O, +optional Cython-compiled speedups are available. + +How Speedups Work +----------------- + +The ``speedups`` package provides a C-compiled replacement for +the ASCII STL reader and writer. When installed, numpy-stl +auto-detects and uses it transparently. + +Installing Speedups +------------------- + +.. code-block:: bash + + pip install numpy-stl[fast] + +This installs the ``speedups`` package as an extra dependency. + +Checking Speedups Status +------------------------ + +.. code-block:: python + + from stl._compat import has_speedups + + print(has_speedups()) # True if speedups installed + +When Speedups Are Disabled +-------------------------- + +Speedups are automatically disabled for non-seekable streams +(e.g., ``stdin``, ``StringIO``). The library falls back to +pure Python in these cases. + +For the CLI tools, pass ``-s`` / ``--disable-speedups`` to force the +pure-Python implementation even when the optional ``speedups`` package +is installed. + +.. note:: + When speedups are enabled, STL solid names are automatically + converted to lowercase. This is a known limitation of the + Cython implementation. diff --git a/docs/images/benchmark_chart.png b/docs/images/benchmark_chart.png new file mode 100644 index 0000000..cb08c0d Binary files /dev/null and b/docs/images/benchmark_chart.png differ diff --git a/docs/images/dragon_render.png b/docs/images/dragon_render.png new file mode 100644 index 0000000..3a97d81 Binary files /dev/null and b/docs/images/dragon_render.png differ diff --git a/docs/index.rst b/docs/index.rst index 5172f02..ec49905 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,19 +1,35 @@ -Welcome to numpy-stl's documentation! -======================================== +numpy-stl +========= -Contents: +A fast library for reading, writing, and modifying STL files, powered by NumPy. .. toctree:: - :maxdepth: 4 + :maxdepth: 2 + :caption: Getting Started - usage - tests - stl + getting-started/installation + getting-started/quickstart -Indices and tables +.. toctree:: + :maxdepth: 2 + :caption: User Guide + + guide/reading-writing + guide/mesh-operations + guide/properties + guide/cli + guide/speedups + +.. toctree:: + :maxdepth: 2 + :caption: API Reference + + api/index + api/mesh + +Indices and Tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/stl.rst b/docs/stl.rst deleted file mode 100644 index a467bb0..0000000 --- a/docs/stl.rst +++ /dev/null @@ -1,48 +0,0 @@ -stl package -=========== - -stl.Mesh --------- - -.. autoclass:: stl.Mesh - :members: - :undoc-members: - :inherited-members: - :show-inheritance: - -stl.main module ---------------- - -.. automodule:: stl.main - :members: - :undoc-members: - :inherited-members: - :show-inheritance: - -stl.base module ---------------- - -.. automodule:: stl.base - :members: - :undoc-members: - :inherited-members: - :show-inheritance: - -stl.mesh module ---------------- - -.. automodule:: stl.mesh - :members: - :undoc-members: - :inherited-members: - :show-inheritance: - -stl.stl module --------------- - -.. automodule:: stl.stl - :members: - :undoc-members: - :inherited-members: - :show-inheritance: - diff --git a/docs/tests.rst b/docs/tests.rst deleted file mode 100644 index 58cc1c8..0000000 --- a/docs/tests.rst +++ /dev/null @@ -1,33 +0,0 @@ -tests and examples -================== - -tests.stl_corruption module ---------------------------- - -.. literalinclude:: ../tests/stl_corruption.py - -tests.test_commandline module ------------------------------ - -.. literalinclude:: ../tests/test_commandline.py - -tests.test_convert module -------------------------- - -.. literalinclude:: ../tests/test_convert.py - -tests.test_mesh module ----------------------- - -.. literalinclude:: ../tests/test_mesh.py - -tests.test_multiple module --------------------------- - -.. literalinclude:: ../tests/test_multiple.py - -tests.test_rotate module ------------------------- - -.. literalinclude:: ../tests/test_rotate.py - diff --git a/docs/usage.rst b/docs/usage.rst deleted file mode 100644 index 6d1662d..0000000 --- a/docs/usage.rst +++ /dev/null @@ -1,3 +0,0 @@ - -.. include :: ../README.rst - diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..88cc3f4 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +# Pre-commit hooks for numpy-stl development +# Install: brew install lefthook && lefthook install +# Or: pip install lefthook && lefthook install + +pre-commit: + parallel: true + commands: + ruff-check: + glob: '*.py' + run: ruff check {staged_files} + ruff-format: + glob: '*.py' + run: ruff format {staged_files} + stage_fixed: true + ty-check: + run: ty check diff --git a/pyproject.toml b/pyproject.toml index cdf91b6..90e8b7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,86 @@ +[build-system] +requires = ["uv_build>=0.11.0,<0.12"] +build-backend = "uv_build" + +[project] +name = "numpy-stl" +version = "3.2.0" +description = "Library to make reading, writing and modifying both binary and ascii STL files easy." +readme = "README.md" +license = "BSD-3-Clause" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Rick van Hattem", email = "Wolph@Wol.ph" }] +classifiers = [ + "Development Status :: 6 - Mature", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Natural Language :: English", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] +dependencies = [ + "numpy", + "python-utils>=3.4.5", +] + +[project.urls] +Homepage = "https://github.com/WoLpH/numpy-stl/" + +[project.scripts] +stl = "stl.main:main" +stl2ascii = "stl.main:to_ascii" +stl2bin = "stl.main:to_binary" + +[project.optional-dependencies] +docs = [ + "sphinx>=8.0; python_version < '3.11'", + "sphinx>=9.0; python_version >= '3.11'", + "furo>=2025.1", +] +tests = [ + "pytest>=9.0", + "pytest-cov>=7.0", + "coverage>=7.0", +] +dev = [ + "numpy-stl[tests]", + "ruff>=0.15.0", + "mypy>=1.0", + "basedpyright>=1.0", + "pyrefly>=0.1", +] +fast = ["speedups>=2.0.0"] +tox = [ + "tox>=4.0", + "tox-uv>=1.0", + "tox-gh-actions>=3.0", +] + +[tool.uv.build-backend] +module-root = "" +module-name = "stl" + +[tool.pytest.ini_options] +doctest_optionflags = "NORMALIZE_WHITESPACE" +python_files = ["stl/*.py", "tests/*.py"] +addopts = [ + "--doctest-modules", + "--cov=stl", + "--cov-report=term-missing", + "--cov-report=html", + "--no-cov-on-fail", + "--ignore=build", + "--basetemp=tmp", +] + [tool.mypy] packages = ["stl"] local_partial_types = true @@ -25,3 +108,10 @@ reportUnusedVariable = false # dupe of F841 [tool.pyrefly] project-includes = ["stl"] +ignore-missing-imports = ["speedups"] + +[[tool.ty.overrides]] +include = ["stl/_compat.py"] + +[tool.ty.overrides.rules] +unresolved-import = "ignore" diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index ce91b88..0000000 --- a/pytest.ini +++ /dev/null @@ -1,16 +0,0 @@ -[pytest] - -doctest_optionflags = NORMALIZE_WHITESPACE - -python_files = - stl/*.py - tests/*.py - -addopts = - --doctest-modules - --cov stl - --cov-report term-missing - --cov-report html - --no-cov-on-fail - --ignore=build - --basetemp=tmp diff --git a/ruff.toml b/ruff.toml index d4a2734..e00b682 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,7 +1,7 @@ # We keep the ruff configuration separate so it can easily be shared across # all projects -target-version = 'py39' +target-version = 'py310' src = ['stl'] exclude = [ @@ -74,6 +74,7 @@ select = [ [lint.per-file-ignores] 'tests/*' = ['SIM115', 'SIM117', 'T201', 'B007'] +'benchmarks/*' = ['INP001', 'T201'] 'docs/*' = ['INP001', 'RUF012'] [lint.pydocstyle] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 1a18eae..0000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[build_sphinx] -source-dir = docs/ -build-dir = docs/_build -all_files = 1 - -[upload_sphinx] -upload-dir = docs/_build/html - -[bdist_wheel] -universal = 0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 5228c37..0000000 --- a/setup.py +++ /dev/null @@ -1,158 +0,0 @@ -import os -import sys -import warnings - -from setuptools import extension, setup -from setuptools.command.build_ext import build_ext - -setup_kwargs = {} - - -def error(*lines): - for _line in lines: - pass - - -try: - from stl import stl - - if not hasattr(stl, 'BaseStl'): - error( - 'ERROR', - 'You have an incompatible stl package installed' - 'Please run "pip uninstall -y stl" first', - ) - sys.exit(1) -except ImportError: - pass - - -try: - import numpy as np - from Cython import Build - - setup_kwargs['ext_modules'] = Build.cythonize( - [ - extension.Extension( - 'stl._speedups', - ['stl/_speedups.pyx'], - include_dirs=[np.get_include()], - ), - ] - ) -except ImportError: - error( - 'WARNING', - 'Cython and Numpy is required for building extension.', - 'Falling back to pure Python implementation.', - ) - -# To prevent importing about and thereby breaking the coverage info we use this -# exec hack -about = {} -with open('stl/__about__.py') as fh: - exec(fh.read(), about) - - -if os.path.isfile('README.rst'): - with open('README.rst') as fh: - long_description = fh.read() -else: - long_description = 'See http://pypi.python.org/pypi/{}/'.format( - about['__package_name__'] - ) - -install_requires = [ - 'numpy', - 'python-utils>=3.4.5', -] - - -tests_require = ['pytest'] - - -class BuildExt(build_ext): - def run(self): - try: - build_ext.run(self) - except Exception as e: - warnings.warn( - f""" - Unable to build speedups module, defaulting to pure Python. Note - that the pure Python version is more than fast enough in most cases - {e!r} - """, - stacklevel=2, - ) - - -if __name__ == '__main__': - setup( - python_requires='>=3.9.0', - name=about['__package_name__'], - version=about['__version__'], - author=about['__author__'], - author_email=about['__author_email__'], - description=about['__description__'], - url=about['__url__'], - license='BSD', - packages=['stl'], - package_data={about['__import_name__']: ['py.typed']}, - long_description=long_description, - tests_require=tests_require, - entry_points={ - 'console_scripts': [ - 'stl = {}.main:main'.format(about['__import_name__']), - 'stl2ascii = {}.main:to_ascii'.format( - about['__import_name__'] - ), - 'stl2bin = {}.main:to_binary'.format(about['__import_name__']), - ], - }, - classifiers=[ - 'Development Status :: 6 - Mature', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: BSD License', - 'Operating System :: OS Independent', - 'Natural Language :: English', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Typing :: Typed', - ], - install_requires=install_requires, - cmdclass=dict( - build_ext=BuildExt, - ), - extras_require={ - 'docs': [ - 'mock', - 'sphinx', - 'python-utils', - ], - 'tests': [ - 'cov-core', - 'coverage', - 'docutils', - 'execnet', - 'numpy', - 'cython', - 'pep8', - 'py', - 'pyflakes', - 'pytest', - 'pytest-cache', - 'pytest-cov', - 'python-utils', - 'Sphinx', - 'flake8', - 'wheel', - ], - }, - **setup_kwargs, - ) diff --git a/stl/__about__.py b/stl/__about__.py index 14fb6ab..6e469d7 100644 --- a/stl/__about__.py +++ b/stl/__about__.py @@ -1,14 +1,18 @@ from typing import Final +try: + from importlib.metadata import version as _version + + __version__: Final[str] = _version('numpy-stl') +except Exception: + __version__: Final[str] = '0.0.0' # type: ignore[misc] + __package_name__: Final[str] = 'numpy-stl' __import_name__: Final[str] = 'stl' -__version__: Final[str] = '3.2.0' __author__: Final[str] = 'Rick van Hattem' __author_email__: Final[str] = 'Wolph@Wol.ph' -__description__: Final[str] = ' '.join( - """ -Library to make reading, writing and modifying both binary and ascii STL files -easy. -""".split() +__description__: Final[str] = ( + 'Library to make reading, writing and modifying' + ' both binary and ascii STL files easy.' ) __url__: Final[str] = 'https://github.com/WoLpH/numpy-stl/' diff --git a/stl/__init__.py b/stl/__init__.py index 4ca0e60..1679a06 100644 --- a/stl/__init__.py +++ b/stl/__init__.py @@ -1,14 +1,25 @@ +'''numpy-stl: fast STL file handling powered by NumPy. + +Read, write, and manipulate STL files with vectorized +array operations. + +Quick start:: + + from stl import mesh + m = mesh.Mesh.from_file('model.stl') + print(len(m), 'triangles') +''' from .base import Dimension, RemoveDuplicates from .mesh import Mesh from .stl import BUFFER_SIZE, COUNT_SIZE, HEADER_SIZE, MAX_COUNT, Mode __all__ = [ 'BUFFER_SIZE', - 'HEADER_SIZE', 'COUNT_SIZE', + 'HEADER_SIZE', 'MAX_COUNT', - 'Mode', 'Dimension', - 'RemoveDuplicates', 'Mesh', + 'Mode', + 'RemoveDuplicates', ] diff --git a/stl/_compat.py b/stl/_compat.py new file mode 100644 index 0000000..92e9f1b --- /dev/null +++ b/stl/_compat.py @@ -0,0 +1,28 @@ +"""Compatibility layer for optional speedups package.""" + +from __future__ import annotations + +import importlib.util +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any + +_speedups_available: bool = importlib.util.find_spec('speedups') is not None + +ascii_read: Any = None +ascii_write: Any = None + +if _speedups_available: + try: + from speedups import ( # noqa: F401 + ascii_read, # type: ignore[assignment] + ascii_write, # type: ignore[assignment] + ) + except ImportError: + _speedups_available = False + + +def has_speedups() -> bool: + """Return True when the external speedups package is installed.""" + return _speedups_available diff --git a/stl/_speedups.pyi b/stl/_speedups.pyi deleted file mode 100644 index 8949785..0000000 --- a/stl/_speedups.pyi +++ /dev/null @@ -1,9 +0,0 @@ -from typing import IO - -import numpy as np -from typing_extensions import Buffer, TypeAlias - -_DataArray: TypeAlias = np.ndarray[tuple[int], np.dtype[np.void]] - -def ascii_read(fh: IO[bytes], buf: Buffer) -> tuple[bytes, _DataArray]: ... -def ascii_write(fh: IO[bytes], name: bytes, data: _DataArray) -> None: ... diff --git a/stl/_speedups.pyx b/stl/_speedups.pyx deleted file mode 100644 index db32f72..0000000 --- a/stl/_speedups.pyx +++ /dev/null @@ -1,204 +0,0 @@ -# cython: language_level=2 -from libc.stdio cimport * -from libc.string cimport memcpy, strcmp, strstr, strcpy - -IF UNAME_SYSNAME == 'Windows': - cdef extern from 'io.h': - int dup(int fd) -ELSE: - cdef extern from 'unistd.h': - int dup(int fd) - -IF UNAME_SYSNAME == 'Linux': - cdef extern from 'locale.h': - ctypedef struct locale_t: - pass - - locale_t uselocale(locale_t __dataset) - locale_t newlocale(int __category_mask, const char *__locale, - locale_t __base) - void freelocale(locale_t __dataset) - - enum: LC_NUMERIC_MASK - -import numpy as np -cimport numpy as np - -np.import_array() - -cdef packed struct Facet: - np.float32_t n[3] - np.float32_t v[3][3] - np.uint16_t attr - -dtype = np.dtype([ - ('normals', np.float32, 3), - ('vectors', np.float32, (3, 3)), - ('attr', np.uint16, (1,)), -]) - -DEF ALLOC_SIZE = 200000 -DEF BUF_SIZE = 8192 -DEF LINE_SIZE = 8192 - -cdef struct s_State: - FILE* fp - char buf[BUF_SIZE] - char line[LINE_SIZE] - size_t pos - size_t size - size_t line_num - int recoverable - -ctypedef s_State State - -cdef char* readline(State* state) except NULL: - - cdef size_t line_pos = 0 - cdef char current; - while True: - if state.pos == state.size: - if feof(state.fp): - if line_pos != 0: - state.line[line_pos] = '\0' - return state.line - raise RuntimeError(state.recoverable, 'Unexpected EOF') - - state.size = fread(state.buf, 1, BUF_SIZE, state.fp) - state.pos = 0 - state.recoverable = 0 - - if line_pos == LINE_SIZE: - raise RuntimeError( - state.recoverable, 'Line longer than %d, probably non-ascii' % - LINE_SIZE) - - current = state.buf[state.pos] - state.pos += 1 - - if line_pos != 0 or (current != ' ' \ - and current != '\t' \ - and current != '\r'): - if current == '\n': - state.line_num += 1 - if line_pos != 0: - state.line[line_pos] = '\0' - return state.line - elif 0x40 < current < 0x5b: - # Change all ascii characters to lower case - state.line[line_pos] = current | 0x60 - line_pos += 1 - else: - state.line[line_pos] = current - line_pos += 1 - - -def ascii_read(fh, buf): - cdef char* line - cdef char name[LINE_SIZE] - cdef np.ndarray[Facet, cast=True] arr = np.zeros(ALLOC_SIZE, dtype = dtype) - cdef size_t offset; - cdef Facet* facet = arr.data - cdef size_t pos = 0 - cdef State state - - - IF UNAME_SYSNAME == 'Linux': - cdef locale_t new_locale = newlocale(LC_NUMERIC_MASK, 'C', - NULL) - cdef locale_t old_locale = uselocale(new_locale) - - try: - state.size = len(buf) - memcpy(state.buf, buf, state.size) - state.pos = 0 - state.line_num = 0 - state.recoverable = 1 - state.fp = fdopen(dup(fh.fileno()), 'rb') - fseek(state.fp, fh.tell(), SEEK_SET) - - line = readline(&state) - - if strstr(line, 'solid') != line: - raise RuntimeError(state.recoverable, - 'Solid name not found (%i:%s)' % (state.line_num, line)) - - strcpy(name, line+5) - - while True: - - line = readline(&state) - line = state.line - - if strstr(line, 'endsolid') != NULL \ - or strstr(line, 'end solid') != NULL: - arr.resize(facet - arr.data, refcheck=False) - return (name).strip(), arr - - if strcmp(line, 'color') == 0: - readline(&state) - continue - elif sscanf(line, '%*s %*s %e %e %e', - facet.n, facet.n+1, facet.n+2) != 3: - raise RuntimeError(state.recoverable, - 'Cannot read normals (%i:%s)' % (state.line_num, line)) - - readline(&state) # outer loop - - for i in range(3): - line = readline(&state) - if sscanf(line, '%*s %e %e %e', - facet.v[i], facet.v[i]+1, facet.v[i]+2) != 3: - raise RuntimeError(state.recoverable, - 'Cannot read vertex (%i:%s)' % (state.line_num, line)) - - readline(&state) # endloop - readline(&state) # endfacet - - facet += 1 - offset = facet - arr.data - if arr.shape[0] == offset: - arr.resize(arr.shape[0] + ALLOC_SIZE, refcheck=False) - facet = arr.data + offset - - finally: - if state.recoverable == 0: - pos = ftell(state.fp) - state.size + state.pos - fclose(state.fp) - fh.seek(pos, SEEK_SET) - - IF UNAME_SYSNAME == 'Linux': - uselocale(old_locale) - freelocale(new_locale) - - -def ascii_write(fh, name, np.ndarray[Facet, mode = 'c', cast=True] arr): - cdef FILE* fp - cdef Facet* facet = arr.data - cdef Facet* end = arr.data + arr.shape[0] - cdef size_t pos = 0 - - try: - fp = fdopen(dup(fh.fileno()), 'wb') - fseek(fp, fh.tell(), SEEK_SET) - fprintf(fp, 'solid %s\n', name) - while facet != end: - fprintf(fp, - 'facet normal %f %f %f\n' - ' outer loop\n' - ' vertex %f %f %f\n' - ' vertex %f %f %f\n' - ' vertex %f %f %f\n' - ' endloop\n' - 'endfacet\n', - facet.n[0], facet.n[1], facet.n[2], - facet.v[0][0], facet.v[0][1], facet.v[0][2], - facet.v[1][0], facet.v[1][1], facet.v[1][2], - facet.v[2][0], facet.v[2][1], facet.v[2][2]) - facet += 1 - fprintf(fp, 'endsolid %s\n', name) - finally: - pos = ftell(fp) - fclose(fp) - fh.seek(pos, SEEK_SET) - diff --git a/stl/base.py b/stl/base.py index 9b4be41..5bbea79 100644 --- a/stl/base.py +++ b/stl/base.py @@ -10,6 +10,7 @@ Final, Literal as L, # noqa: N817 SupportsIndex, + TypeAlias, TypeVar, cast, overload, @@ -21,40 +22,39 @@ if TYPE_CHECKING: # pragma: no cover from types import EllipsisType - from typing import Protocol + from typing import Protocol, TypeAlias # this won't be changing anytime soon, so safe to import here from numpy._typing import _ArrayLikeFloat_co, _ArrayLikeInt_co - from typing_extensions import TypeAlias # pyrefly: ignore[invalid-inheritance] class _Logged(logger.LoggerProtocol, Protocol): # pragma: no cover logger: logging.Logger - _Dedupe: 'TypeAlias' = 'RemoveDuplicates | int' - _ToAxis: 'TypeAlias' = npt.NDArray[np.integer] | abc.Sequence[int] - _ToPoint: 'TypeAlias' = ( + _Dedupe: TypeAlias = 'RemoveDuplicates | int' + _ToAxis: TypeAlias = npt.NDArray[np.integer] | abc.Sequence[int] + _ToPoint: TypeAlias = ( float | abc.Sequence[float] | npt.NDArray[np.floating | np.integer] ) - _ToTranslation: 'TypeAlias' = ( + _ToTranslation: TypeAlias = ( abc.Sequence[_ToPoint] | npt.NDArray[np.floating | np.integer] ) # same as used by `np.ndarray.__getitem__` - _ToIndex: 'TypeAlias' = ( + _ToIndex: TypeAlias = ( SupportsIndex | slice | EllipsisType | _ArrayLikeInt_co | None ) - _ToIndices: 'TypeAlias' = _ToIndex | tuple[_ToIndex, ...] + _ToIndices: TypeAlias = _ToIndex | tuple[_ToIndex, ...] # specific to 2-d arrays - _ToSlice2_0: 'TypeAlias' = tuple[SupportsIndex, SupportsIndex] - _ToSlice2_1: 'TypeAlias' = ( + _ToSlice2_0: TypeAlias = tuple[SupportsIndex, SupportsIndex] + _ToSlice2_1: TypeAlias = ( int | np.integer | tuple[slice | EllipsisType, int] | tuple[int, slice | EllipsisType] ) - _ToSlice2_2: 'TypeAlias' = ( + _ToSlice2_2: TypeAlias = ( slice | tuple[()] | tuple[slice, slice] @@ -63,27 +63,29 @@ class _Logged(logger.LoggerProtocol, Protocol): # pragma: no cover | EllipsisType ) -_bool_1d: 'TypeAlias' = np.ndarray[tuple[int], np.dtype[np.bool_]] -_intp_1d: 'TypeAlias' = np.ndarray[tuple[int], np.dtype[np.intp]] -_u16_1d: 'TypeAlias' = np.ndarray[tuple[int], np.dtype[np.uint16]] -_u16_2d: 'TypeAlias' = np.ndarray[tuple[int, int], np.dtype[np.uint16]] -_f32_1d: 'TypeAlias' = np.ndarray[tuple[int], np.dtype[np.float32]] -_f32_2d: 'TypeAlias' = np.ndarray[tuple[int, int], np.dtype[np.float32]] -_f32_3d: 'TypeAlias' = np.ndarray[tuple[int, int, int], np.dtype[np.float32]] -_f64_2d: 'TypeAlias' = np.ndarray[tuple[int, int], np.dtype[np.float64]] +_bool_1d: TypeAlias = np.ndarray[tuple[int], np.dtype[np.bool_]] +_intp_1d: TypeAlias = np.ndarray[tuple[int], np.dtype[np.intp]] +_u16_1d: TypeAlias = np.ndarray[tuple[int], np.dtype[np.uint16]] +_u16_2d: TypeAlias = np.ndarray[tuple[int, int], np.dtype[np.uint16]] +_f32_1d: TypeAlias = np.ndarray[tuple[int], np.dtype[np.float32]] +_f32_2d: TypeAlias = np.ndarray[tuple[int, int], np.dtype[np.float32]] +_f32_3d: TypeAlias = np.ndarray[tuple[int, int, int], np.dtype[np.float32]] +_f64_2d: TypeAlias = np.ndarray[tuple[int, int], np.dtype[np.float64]] # {"normals": _float32_1d, "vectors": _float32_2d, "attr": _uint16_1d} -_data_1d: 'TypeAlias' = np.ndarray[tuple[int], np.dtype[np.void]] +_data_1d: TypeAlias = np.ndarray[tuple[int], np.dtype[np.void]] #: When removing empty areas, remove areas that are smaller than this -AREA_SIZE_THRESHOLD: Final[L[0]] = 0 +AREA_SIZE_THRESHOLD: int = 0 #: Vectors in a point -VECTORS: Final[L[3]] = 3 +VECTORS: int = 3 #: Dimensions used in a vector -DIMENSIONS: Final[L[3]] = 3 +DIMENSIONS: int = 3 class Dimension(enum.IntEnum): + """Named indices for X/Y/Z axes.""" + #: X index (for example, `mesh.v0[0][X]`) X = 0 #: Y index (for example, `mesh.v0[0][Y]`) @@ -93,15 +95,15 @@ class Dimension(enum.IntEnum): # For backwards compatibility, leave the original references -X: Final[L[Dimension.X]] = Dimension.X -Y: Final[L[Dimension.Y]] = Dimension.Y -Z: Final[L[Dimension.Z]] = Dimension.Z +X: L[Dimension.X] = Dimension.X +Y: L[Dimension.Y] = Dimension.Y +Z: L[Dimension.Z] = Dimension.Z class RemoveDuplicates(enum.Enum): - """ - Choose whether to remove no duplicates, leave only a single of the - duplicates or remove all duplicates (leaving holes). + """Strategy for handling duplicate triangles. + + Use with :meth:`BaseMesh.remove_duplicate_polygons`. """ NONE = 0 @@ -110,6 +112,14 @@ class RemoveDuplicates(enum.Enum): @classmethod def map(cls, /, value: '_Dedupe') -> 'RemoveDuplicates': + """Convert an int or RemoveDuplicates to RemoveDuplicates. + + Args: + value: Integer or RemoveDuplicates enum value. + + Returns: + The corresponding RemoveDuplicates member. + """ if value is True: return cls.SINGLE elif value and value in cls: @@ -122,13 +132,20 @@ def map(cls, /, value: '_Dedupe') -> 'RemoveDuplicates': def logged(class_: type[_LoggedT]) -> type[_LoggedT]: - # For some reason the Logged baseclass is not properly initiated on Linux - # systems while this works on OS X. Please let me know if you can tell me - # what silly mistake I made here + """Initialize the logger for a class. + + Workaround for the Logged base class not properly + initializing on Linux. + Args: + class_: The class to add logging to. + + Returns: + The class with logger initialized. + """ logger_name = cast( 'str', - logger.Logged._Logged__get_name(__name__, class_.__name__), # type: ignore[attr-defined] + logger.Logged._Logged__get_name(__name__, class_.__name__), # type: ignore[attr-defined, ty:unresolved-attribute] ) class_.logger = logging.getLogger(logger_name) @@ -142,28 +159,24 @@ def logged(class_: type[_LoggedT]) -> type[_LoggedT]: @logged class BaseMesh(logger.Logged, abc.Mapping['_ToIndices', np.ndarray]): - """ - Mesh object with easy access to the vectors through v0, v1 and v2. - The normals, areas, min, max and units are calculated automatically. - - :param numpy.array data: The data for this mesh - :param bool calculate_normals: Whether to calculate the normals - :param bool remove_empty_areas: Whether to remove triangles with 0 area - (due to rounding errors for example) - - :ivar str name: Name of the solid, only exists in ASCII files - :ivar numpy.array data: Data as :func:`BaseMesh.dtype` - :ivar numpy.array points: All points (Nx9) - :ivar numpy.array normals: Normals for this mesh, calculated automatically - by default (Nx3) - :ivar numpy.array vectors: Vectors in the mesh (Nx3x3) - :ivar numpy.array attr: Attributes per vector (used by binary STL) - :ivar numpy.array x: Points on the X axis by vertex (Nx3) - :ivar numpy.array y: Points on the Y axis by vertex (Nx3) - :ivar numpy.array z: Points on the Z axis by vertex (Nx3) - :ivar numpy.array v0: Points in vector 0 (Nx3) - :ivar numpy.array v1: Points in vector 1 (Nx3) - :ivar numpy.array v2: Points in vector 2 (Nx3) + """Mesh object with easy access to vectors through v0, v1, v2. + + Normals, areas, min, max, and units are calculated + automatically. + + Args: + data: Structured NumPy array with dtype + :attr:`BaseMesh.dtype`. + calculate_normals: Whether to recalculate normals + after loading. Defaults to True. + remove_empty_areas: Whether to remove triangles + with zero area. Defaults to False. + remove_duplicate_polygons: Strategy for handling + duplicate triangles. Defaults to + :attr:`RemoveDuplicates.NONE`. + name: Name of the solid (from ASCII STL header). + speedups: Use Cython speedups when available. + Defaults to True. >>> data = np.zeros(10, dtype=BaseMesh.dtype) >>> mesh = BaseMesh(data, remove_empty_areas=False) @@ -236,15 +249,17 @@ class BaseMesh(logger.Logged, abc.Mapping['_ToIndices', np.ndarray]): #: - normals: :func:`numpy.float32`, `(3, )` #: - vectors: :func:`numpy.float32`, `(3, 3)` #: - attr: :func:`numpy.uint16`, `(1, )` - dtype: ClassVar[np.dtype[np.void]] = np.dtype([ - ('normals', np.float32, (3,)), - ('vectors', np.float32, (3, 3)), - ('attr', np.uint16, (1,)), - ]).newbyteorder('<') # Even on big endian arches, use little e. + dtype: ClassVar[np.dtype[np.void]] = np.dtype( + [ + ('normals', np.float32, (3,)), + ('vectors', np.float32, (3, 3)), + ('attr', np.uint16, (1,)), + ] + ).newbyteorder('<') # Even on big endian arches, use little e. speedups: Final[bool] - name: Final['bytes | str'] - data: Final[_data_1d] + name: 'bytes | str' + data: _data_1d _min: _f32_1d _max: _f32_1d @@ -284,8 +299,9 @@ def __init__( @property def attr(self) -> _u16_2d: + """Per-triangle attribute field (uint16), shape (N, 1).""" # https://github.com/numpy/numpy/pull/30261 - return self.data['attr'] # type: ignore[return-value] + return self.data['attr'] # type: ignore[return-value, ty:invalid-return-type] @attr.setter def attr(self, value: '_ArrayLikeInt_co', /) -> None: @@ -293,8 +309,23 @@ def attr(self, value: '_ArrayLikeInt_co', /) -> None: @property def normals(self) -> _f32_2d: + """Per-triangle normal vectors, shape (N, 3). + + Lazily computed on first access. Call + :meth:`update_normals` after modifying vertices + to refresh. + + Example: + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(1, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> m.normals[0].tolist() + [0.0, 0.0, 1.0] + """ # https://github.com/numpy/numpy/pull/30261 - return self.data['normals'] # type: ignore[return-value] + return self.data['normals'] # type: ignore[return-value, ty:invalid-return-type] @normals.setter def normals(self, value: '_ArrayLikeFloat_co', /) -> None: @@ -302,8 +333,9 @@ def normals(self, value: '_ArrayLikeFloat_co', /) -> None: @property def vectors(self) -> _f32_3d: + """Triangle vertices as (N, 3, 3) array.""" # https://github.com/numpy/numpy/pull/30261 - return self.data['vectors'] # type: ignore[return-value] + return self.data['vectors'] # type: ignore[return-value, ty:invalid-return-type] @vectors.setter def vectors(self, value: '_ArrayLikeFloat_co', /) -> None: @@ -311,6 +343,7 @@ def vectors(self, value: '_ArrayLikeFloat_co', /) -> None: @property def points(self) -> _f32_2d: + """All vertices flattened as (N, 9) array.""" return self.vectors.reshape(self.data.size, 9) @points.setter @@ -319,6 +352,7 @@ def points(self, value: '_ArrayLikeFloat_co', /) -> None: @property def v0(self) -> _f32_2d: + """First vertex of each triangle, shape (N, 3).""" return self.vectors[:, 0] @v0.setter @@ -327,6 +361,7 @@ def v0(self, value: '_ArrayLikeFloat_co', /) -> None: @property def v1(self) -> _f32_2d: + """Second vertex of each triangle, shape (N, 3).""" return self.vectors[:, 1] @v1.setter @@ -335,6 +370,7 @@ def v1(self, value: '_ArrayLikeFloat_co', /) -> None: @property def v2(self) -> _f32_2d: + """Third vertex of each triangle, shape (N, 3).""" return self.vectors[:, 2] @v2.setter @@ -343,6 +379,7 @@ def v2(self, value: '_ArrayLikeFloat_co', /) -> None: @property def x(self) -> _f32_2d: + """X coordinates by vertex, shape (N, 3).""" return self.points[:, Dimension.X :: 3] @x.setter @@ -351,6 +388,7 @@ def x(self, value: '_ArrayLikeFloat_co', /) -> None: @property def y(self) -> _f32_2d: + """Y coordinates by vertex, shape (N, 3).""" return self.points[:, Dimension.Y :: 3] @y.setter @@ -359,6 +397,7 @@ def y(self, value: '_ArrayLikeFloat_co', /) -> None: @property def z(self) -> _f32_2d: + """Z coordinates by vertex, shape (N, 3).""" return self.points[:, Dimension.Z :: 3] @z.setter @@ -370,6 +409,19 @@ def remove_duplicate_polygons( data: _data_1d, value: '_Dedupe' = RemoveDuplicates.SINGLE, ) -> _data_1d: + """Remove duplicate triangles from mesh data. + + Args: + data: Structured mesh array. + value: Deduplication strategy. Use + :attr:`RemoveDuplicates.SINGLE` to keep one + copy, :attr:`RemoveDuplicates.ALL` to remove + all copies, or :attr:`RemoveDuplicates.NONE` + to keep everything. + + Returns: + Filtered mesh data array. + """ value = RemoveDuplicates.map(value) polygons: _f32_2d = data['vectors'].sum(axis=1) # Get a sorted list of indices @@ -401,8 +453,20 @@ def remove_duplicate_polygons( @staticmethod def remove_empty_areas(data: _data_1d) -> _data_1d: + """Remove triangles with zero surface area. + + Filters out degenerate triangles where all three + vertices are collinear or coincident. + + Args: + data: Structured mesh array. + + Returns: + Filtered mesh data array with zero-area + triangles removed. + """ # https://github.com/numpy/numpy/pull/30261 - vectors: _f32_3d = data['vectors'] # type: ignore[assignment] + vectors: _f32_3d = data['vectors'] # type: ignore[assignment, ty:invalid-assignment] v0 = vectors[:, 0] v1 = vectors[:, 1] v2 = vectors[:, 2] @@ -415,8 +479,17 @@ def update_normals( update_areas: bool = True, update_centroids: bool = True, ) -> None: - """Update the normals, areas, and centroids for all points""" - normals: _f32_2d = np.cross(self.v1 - self.v0, self.v2 - self.v0) + """Recalculate normals from current vertex positions. + + Also refreshes areas and centroids by default. + + Args: + update_areas: Whether to also refresh cached + areas. Defaults to True. + update_centroids: Whether to also refresh cached + centroids. Defaults to True. + """ + normals: _f32_2d = np.cross(self.v1 - self.v0, self.v2 - self.v0) # pyrefly: ignore if update_areas: self.update_areas(normals) @@ -427,6 +500,16 @@ def update_normals( self.normals[:] = normals def get_unit_normals(self) -> _f32_2d: + """Return a copy of normals normalized to unit length. + + Unlike the :attr:`units` property, this method + always recomputes from the current :attr:`normals` + array. + + Returns: + Array of shape (N, 3) with unit-length normals. + Zero-length normals remain as zeros. + """ normals = self.normals.copy() normal: _f32_1d = np.linalg.norm(normals, axis=1) non_zero = normal > 0 @@ -435,34 +518,71 @@ def get_unit_normals(self) -> _f32_2d: return normals def update_min(self) -> None: + """Refresh the cached bounding box minimum.""" self._min = self.vectors.min(axis=(0, 1)) def update_max(self) -> None: + """Refresh the cached bounding box maximum.""" self._max = self.vectors.max(axis=(0, 1)) def update_areas(self, normals: '_f32_2d | None' = None) -> None: + """Refresh the cached per-triangle areas. + + Args: + normals: Pre-computed cross products. If None, + recomputes from current vertices. + """ if normals is None: - normals = np.cross(self.v1 - self.v0, self.v2 - self.v0) + normals = np.cross(self.v1 - self.v0, self.v2 - self.v0) # pyrefly: ignore - areas = 0.5 * np.sqrt((normals**2).sum(axis=1)) + areas = 0.5 * np.sqrt((normals**2).sum(axis=1)) # pyrefly: ignore self._areas = areas.reshape((areas.size, 1)) def update_centroids(self) -> None: + """Refresh the cached per-triangle centroids.""" self._centroids = np.mean([self.v0, self.v1, self.v2], axis=0) def check(self, exact: bool = False) -> bool: - """Check the mesh is valid or not - - :param bool exact: Perform exact checks. + """Check whether the mesh is valid (closed). + + Args: + exact: If True, perform an exact edge-matching + check. If False, use a faster normal-sum + heuristic. + + Returns: + True if the mesh is closed, False otherwise. + + Warning: + The non-exact check (``exact=False``) can + produce false positives and false negatives. + For reliable results, use ``exact=True``. See + `#198 `_ + and `#213 `_. """ return self.is_closed(exact=exact) def is_closed(self, exact: bool = False) -> bool: # pragma: no cover - """Check the mesh is closed or not + """Check whether the mesh is watertight. - :param bool exact: Perform a exact check on edges. - """ + A closed mesh has every edge shared by exactly two + triangles with consistent winding. + Args: + exact: If True, checks directed edges for + matching pairs. If False, uses a faster + normal-sum heuristic. + + Returns: + True if the mesh is closed, False otherwise. + + Warning: + The non-exact check (``exact=False``) can give + false positives and false negatives for certain + mesh geometries. Use ``exact=True`` for reliable + results. See + `#198 `_. + """ if exact: reversed_triangles: _bool_1d = ( np.cross(self.v1 - self.v0, self.v2 - self.v0) * self.normals @@ -512,14 +632,34 @@ def is_closed(self, exact: bool = False) -> bool: # pragma: no cover return False def get_mass_properties(self) -> tuple[np.float32, _f32_1d, _f64_2d]: - """ - Evaluate and return a tuple with the following elements: - - the volume - - the position of the center of gravity (COG) - - the inertia matrix expressed at the COG - - Documentation can be found here: - http://www.geometrictools.com/Documentation/PolyhedralMassProperties.pdf + """Compute volume, center of gravity, and inertia. + + Uses the polyhedral mass properties algorithm from + Eberly (Geometric Tools). + + Returns: + A tuple of (volume, center_of_gravity, inertia): + + - **volume** -- Mesh volume as float32. + - **center_of_gravity** -- COG as (3,) array. + - **inertia** -- Inertia tensor as (3, 3) array + expressed at the COG. + + Raises: + RuntimeError: If the mesh is not closed. + + Example: + >>> from stl import mesh + >>> m = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl') + >>> vol, cog, inertia = m.get_mass_properties() + >>> float(vol) > 0 + True + + Warning: + This method calls ``check(exact=True)`` + internally. If the mesh is not watertight, + a ``RuntimeError`` is raised. Use + :meth:`is_closed` to verify beforehand. """ self.check(True) @@ -568,7 +708,14 @@ def subexpression(x: _f32_2d) -> tuple[_f32_1d, ...]: return volume, cog, inertia def is_convex(self) -> bool: - """Return True if the mesh is convex, False otherwise.""" + """Return True if the mesh is convex. + + Tests whether every vertex lies on or behind every + face plane. + + Returns: + True if convex, False otherwise. + """ # For each face, project every vertex onto the normal vector and make # sure it isn't longer than the projection of the face itself. # The dot product is a scaled projection: (a dot b) = |a||b| cos(angle) @@ -582,6 +729,7 @@ def is_convex(self) -> bool: return True def update_units(self) -> None: + """Refresh the cached unit normal vectors.""" units = self.normals.copy() non_zero_areas = self.areas > 0 areas = self.areas @@ -593,7 +741,7 @@ def update_units(self) -> None: ) if non_zero_areas.any(): - non_zero_areas.shape = non_zero_areas.shape[0] + non_zero_areas = non_zero_areas.reshape(non_zero_areas.shape[0]) areas = np.hstack((2 * areas[non_zero_areas],) * DIMENSIONS) units[non_zero_areas] /= areas @@ -601,17 +749,19 @@ def update_units(self) -> None: @staticmethod def rotation_matrix(axis: '_ToAxis', theta: float) -> _f64_2d: - """ - Generate a rotation matrix to Rotate the matrix over the given axis by - the given theta (angle) + """Generate a 3x3 rotation matrix. + + Uses the Euler-Rodrigues formula for fast rotation + matrix construction. - Uses the `Euler-Rodrigues - `_ - formula for fast rotations. + Args: + axis: Axis to rotate around as [x, y, z]. + theta: Rotation angle in radians. Use + ``math.radians()`` to convert from degrees. - :param numpy.array axis: Axis to rotate over (x, y, z) - :param float theta: Rotation angle in radians, use `math.radians` to - convert degrees to radians if needed. + Returns: + A (3, 3) rotation matrix. Returns the identity + matrix if the axis is zero. """ axis_ = np.asarray(axis) # No need to rotate if there is no actual rotation @@ -630,11 +780,13 @@ def rotation_matrix(axis: '_ToAxis', theta: float) -> _f64_2d: ca, cb, cc, cd = powers[8:12] # noqa: RUF059 da, db, dc, dd = powers[12:16] # noqa: RUF059 - return np.array([ - [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], - [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], - [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc], - ]) + return np.array( + [ + [aa + bb - cc - dd, 2 * (bc + ad), 2 * (bd - ac)], + [2 * (bc - ad), aa + cc - bb - dd, 2 * (cd + ab)], + [2 * (bd + ac), 2 * (cd - ab), aa + dd - bb - cc], + ] + ) def rotate( self, @@ -642,21 +794,28 @@ def rotate( theta: float = 0, point: '_ToPoint | None' = None, ) -> None: - """ - Rotate the matrix over the given axis by the given theta (angle) - - Uses the :py:func:`rotation_matrix` in the background. - - .. note:: Note that the `point` was accidentaly inverted with the - old version of the code. To get the old and incorrect behaviour - simply pass `-point` instead of `point` or `-numpy.array(point)` if - you're passing along an array. - - :param numpy.array axis: Axis to rotate over (x, y, z) - :param float theta: Rotation angle in radians, use `math.radians` to - convert degrees to radians if needed. - :param numpy.array point: Rotation point so manual translation is not - required + """Rotate the mesh around an axis. + + Args: + axis: Axis to rotate around as [x, y, z]. + theta: Rotation angle in radians. Use + ``math.radians()`` to convert from degrees. + point: Optional point to rotate around. If + None, rotates around the origin. + + Example: + >>> import math + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(1, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> m.rotate([0, 0, 1], math.radians(90)) + + Warning: + In older versions, the ``point`` parameter was + accidentally inverted. If you relied on the old + behavior, pass ``-point`` instead. """ # No need to rotate if there is no actual rotation if not theta: @@ -669,15 +828,19 @@ def rotate_using_matrix( rotation_matrix: '_f32_2d | _f64_2d', point: '_ToPoint | None' = None, ) -> None: + """Rotate using a pre-computed rotation matrix. + + Args: + rotation_matrix: A (3, 3) rotation matrix. + point: Optional point to rotate around. If + None, rotates around the origin. + + Warning: + This method produces clockwise rotations for + positive angles, which is arguably incorrect + but retained for backwards compatibility. See + `#166 `_. """ - Rotate using a given rotation matrix and optional rotation point - - Note that this rotation produces clockwise rotations for positive - angles which is arguably incorrect but will remain for legacy reasons. - For more details, read here: - https://github.com/WoLpH/numpy-stl/issues/166 - """ - identity = np.identity(rotation_matrix.shape[0]) # No need to rotate if there is no actual rotation if not rotation_matrix.any() or (identity == rotation_matrix).all(): @@ -708,10 +871,23 @@ def _rotate(matrix: _f32_2d) -> _f64_2d: self.vectors[:, i] = _rotate(self.vectors[:, i]) def translate(self, translation: '_ToTranslation') -> None: - """ - Translate the mesh in the three directions - - :param numpy.array translation: Translation vector (x, y, z) + """Translate (move) the mesh. + + Args: + translation: Translation vector [x, y, z]. + + Raises: + AssertionError: If translation is not length 3. + + Example: + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(1, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> m.translate([10, 20, 30]) + >>> float(m.v0[0][0]) + 10.0 """ assert len(translation) == 3, 'Translation vector must be of length 3' self.x += translation[0] @@ -719,15 +895,18 @@ def translate(self, translation: '_ToTranslation') -> None: self.z += translation[2] def transform(self, matrix: '_f32_2d | _f64_2d') -> None: - """ - Transform the mesh with a rotation and a translation stored in a - single 4x4 matrix - - :param numpy.array matrix: Transform matrix with shape (4, 4), where - matrix[0:3, 0:3] represents the rotation - part of the transformation - matrix[0:3, 3] represents the translation - part of the transformation + """Apply a 4x4 transformation matrix. + + The upper-left 3x3 submatrix is the rotation. + The rightmost column (0:3, 3) is the translation. + + Args: + matrix: A (4, 4) transformation matrix. The + rotation part must have unit determinant. + + Raises: + AssertionError: If matrix shape is not (4, 4) + or rotation determinant is not 1.0. """ is_a_4x4_matrix = matrix.shape == (4, 4) assert is_a_4x4_matrix, 'Transformation matrix must be of shape (4, 4)' @@ -742,7 +921,16 @@ def transform(self, matrix: '_f32_2d | _f64_2d') -> None: @property def min_(self) -> _f32_1d: - """Mesh minimum value""" + """Bounding box minimum corner, shape (3,). + + Lazily computed and cached. Call :meth:`update_min` + after modifying vertices to refresh. + + Warning: + This value is cached on first access. If you + modify vertices, call :meth:`update_min` to + refresh. + """ try: return self._min except AttributeError: @@ -755,7 +943,16 @@ def min_(self, min_: _f32_1d, /) -> None: @property def max_(self) -> _f32_1d: - """Mesh maximum value""" + """Bounding box maximum corner, shape (3,). + + Lazily computed and cached. Call :meth:`update_max` + after modifying vertices to refresh. + + Warning: + This value is cached on first access. If you + modify vertices, call :meth:`update_max` to + refresh. + """ try: return self._max except AttributeError: @@ -768,7 +965,26 @@ def max_(self, max_: _f32_1d, /) -> None: @property def areas(self) -> _f32_2d: - """Mesh areas""" + """Per-triangle surface areas, shape (N, 1). + + Lazily computed and cached. Call + :meth:`update_areas` after modifying vertices + to refresh. + + Example: + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(2, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> float(m.areas[0][0]) + 0.5 + + Warning: + This value is cached on first access. If you + modify vertices, call :meth:`update_areas` to + get correct results. + """ try: return self._areas except AttributeError: @@ -781,7 +997,20 @@ def areas(self, areas: _f32_2d, /) -> None: @property def centroids(self) -> _f32_2d: - """Mesh centroids""" + """Per-triangle centroids, shape (N, 3). + + Lazily computed and cached. Call + :meth:`update_centroids` after modifying vertices. + + Example: + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(1, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [3, 0, 0], [0, 3, 0]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> m.centroids[0].tolist() + [1.0, 1.0, 0.0] + """ try: return self._centroids except AttributeError: @@ -794,7 +1023,20 @@ def centroids(self, centroids: _f32_2d, /) -> None: @property def units(self) -> _f32_2d: - """Mesh unit vectors""" + """Per-triangle unit normal vectors, shape (N, 3). + + Lazily computed and cached. Call + :meth:`update_units` after modifying vertices. + + Example: + >>> import numpy as np + >>> from stl.base import BaseMesh + >>> data = np.zeros(1, dtype=BaseMesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> m = BaseMesh(data, remove_empty_areas=False) + >>> m.units[0].tolist() + [0.0, 0.0, 1.0] + """ try: return self._units except AttributeError: @@ -826,6 +1068,13 @@ def __len__(self) -> int: def __iter__(self) -> abc.Iterator[_f32_1d]: # pyright: ignore[reportIncompatibleMethodOverride] yield from self.points + def __eq__(self, other: object) -> bool: + if self is other: + return True + return NotImplemented + + __hash__ = object.__hash__ + def __repr__(self) -> str: return f'' @@ -833,7 +1082,27 @@ def get_mass_properties_with_density( self, density: float, ) -> tuple[np.float32, np.float32, _f32_1d, _f64_2d]: - # add density for mesh,density unit kg/m3 when mesh is unit is m + """Compute mass properties with a given density. + + Like :meth:`get_mass_properties` but scales volume + to mass using the provided density. + + Args: + density: Material density in consistent units + (e.g., kg/m^3 when mesh units are meters). + + Returns: + A tuple of (volume, mass, cog, inertia): + + - **volume** -- Mesh volume. + - **mass** -- Volume * density. + - **cog** -- Center of gravity as (3,) array. + - **inertia** -- Inertia tensor as (3, 3) + array. + + Raises: + RuntimeError: If the mesh is not closed. + """ self.check(True) def subexpression(x: _f32_2d) -> tuple[_f32_1d, ...]: diff --git a/stl/main.py b/stl/main.py index 68b5294..fa3874b 100644 --- a/stl/main.py +++ b/stl/main.py @@ -49,10 +49,9 @@ def _get_name(args: argparse.Namespace) -> str: args.name, getattr(args.outfile, 'name', None), getattr(args.infile, 'name', None), - 'numpy-stl-%06d' % random.randint(0, 1_000_000), # noqa: UP031 ] - for name in names: # pragma: no branch + for name in names: if not isinstance(name, str): continue elif name.startswith('<'): # pragma: no cover @@ -62,10 +61,17 @@ def _get_name(args: argparse.Namespace) -> str: continue else: return name - return None # type: ignore[return-value] # pragma: no cover + + return 'numpy-stl-%06d' % random.randint(0, 1_000_000) # noqa: UP031 def main() -> None: + """CLI entry point for the ``stl`` command. + + Converts between ASCII and binary STL formats. + Supports ``-a`` (force ASCII), ``-b`` (force binary), + ``-n`` (keep file normals), and ``-s`` (disable speedups). + """ parser = _get_parser('Convert STL files from ascii to binary and back') parser.add_argument( '-a', @@ -103,6 +109,11 @@ def main() -> None: def to_ascii() -> None: + """CLI entry point for the ``stl2ascii`` command. + + Converts an STL file to ASCII format. + Supports ``-n`` (keep file normals) and ``-s`` (disable speedups). + """ parser = _get_parser('Convert STL files to ASCII (text) format') args = parser.parse_args() name = _get_name(args) @@ -122,6 +133,11 @@ def to_ascii() -> None: def to_binary() -> None: + """CLI entry point for the ``stl2bin`` command. + + Converts an STL file to binary format. + Supports ``-n`` (keep file normals) and ``-s`` (disable speedups). + """ parser = _get_parser('Convert STL files to binary format') args = parser.parse_args() name = _get_name(args) diff --git a/stl/mesh.py b/stl/mesh.py index b713202..cbba70b 100644 --- a/stl/mesh.py +++ b/stl/mesh.py @@ -2,4 +2,20 @@ class Mesh(stl.BaseStl): - pass + """Primary user-facing class for STL mesh operations. + + Inherits all functionality from + :class:`~stl.stl.BaseStl` and + :class:`~stl.base.BaseMesh`. Use class methods + like :meth:`from_file` and :meth:`from_multi_file` + to load STL files, or pass a structured NumPy array + to the constructor. + + Example: + >>> import numpy as np + >>> from stl import mesh + >>> data = np.zeros(1, dtype=mesh.Mesh.dtype) + >>> m = mesh.Mesh(data, remove_empty_areas=False) + >>> len(m) + 1 + """ diff --git a/stl/ply.py b/stl/ply.py new file mode 100644 index 0000000..dbec549 --- /dev/null +++ b/stl/ply.py @@ -0,0 +1,525 @@ +"""PLY (Polygon File Format) reader. + +Supports ASCII, binary little-endian, and binary big-endian +PLY files. Binary formats raise ``ValueError`` until the +binary reader is implemented (Task 3). +""" + +from __future__ import annotations + +import struct +from typing import IO, Any + +import numpy as np + +# Mapping from PLY type names to (struct format, numpy dtype, byte size). +# Covers both the verbose and short type names from the PLY spec. +_PLY_TYPES: dict[str, tuple[str, type[Any], int]] = { + 'char': ('b', np.int8, 1), + 'int8': ('b', np.int8, 1), + 'uchar': ('B', np.uint8, 1), + 'uint8': ('B', np.uint8, 1), + 'short': ('h', np.int16, 2), + 'int16': ('h', np.int16, 2), + 'ushort': ('H', np.uint16, 2), + 'uint16': ('H', np.uint16, 2), + 'int': ('i', np.int32, 4), + 'int32': ('i', np.int32, 4), + 'uint': ('I', np.uint32, 4), + 'uint32': ('I', np.uint32, 4), + 'float': ('f', np.float32, 4), + 'float32': ('f', np.float32, 4), + 'double': ('d', np.float64, 8), + 'float64': ('d', np.float64, 8), +} + + +class _Property: + """A single PLY property definition.""" + + __slots__ = ('count_type', 'is_list', 'item_type', 'name', 'type_name') + + def __init__( + self, + name: str, + type_name: str, + is_list: bool = False, + count_type: str = '', + item_type: str = '', + ) -> None: + self.name = name + self.type_name = type_name + self.is_list = is_list + self.count_type = count_type + self.item_type = item_type + + +class _Element: + """A PLY element definition (e.g. vertex, face).""" + + __slots__ = ('count', 'name', 'properties') + + def __init__(self, name: str, count: int) -> None: + self.name = name + self.count = count + self.properties: list[_Property] = [] + + +def _parse_property( + parts: list[str], +) -> _Property: + """Parse a PLY property line into a _Property.""" + if parts[1] == 'list': + return _Property( + name=parts[4], + type_name='list', + is_list=True, + count_type=parts[2], + item_type=parts[3], + ) + return _Property( + name=parts[2], + type_name=parts[1], + ) + + +def _parse_header( + fh: IO[bytes], +) -> tuple[str, list[_Element], str]: + """Parse the PLY header from an open binary file. + + Returns: + A tuple of (format_string, elements, object_name). + format_string is one of 'ascii', + 'binary_little_endian', or 'binary_big_endian'. + """ + magic = fh.readline().strip() + if magic != b'ply': + raise ValueError(f'Not a PLY file (expected "ply", got {magic!r})') + + format_str = '' + elements: list[_Element] = [] + obj_name = 'ply' + current: _Element | None = None + + for raw in iter(fh.readline, b''): + line = raw.decode('ascii', errors='replace').strip() + if line == 'end_header': + break + + parts = line.split() + if not parts: + continue + + keyword = parts[0] + if keyword == 'format': + format_str = parts[1] + elif keyword == 'element': + current = _Element(parts[1], int(parts[2])) + elements.append(current) + elif keyword == 'property' and current is not None: + current.properties.append(_parse_property(parts)) + elif keyword == 'obj_info': + obj_name = ' '.join(parts[1:]) + else: + raise ValueError('Unexpected end of file while parsing header') + + if not format_str: + raise ValueError('No format line found in PLY header') + + return format_str, elements, obj_name + + +def _find_elements( + elements: list[_Element], +) -> tuple[_Element, _Element]: + """Find the vertex and face elements. + + Returns: + (vertex_element, face_element) + + Raises: + ValueError: If vertex or face element is missing. + """ + vertex: _Element | None = None + face: _Element | None = None + + for elem in elements: + if elem.name == 'vertex': + vertex = elem + elif elem.name == 'face': + face = elem + + if vertex is None: + raise ValueError('PLY file has no vertex element') + if face is None: + raise ValueError('PLY file has no face element') + + return vertex, face + + +def _find_xyz_indices( + vertex_elem: _Element, +) -> tuple[int, int, int]: + """Find the indices of x, y, z properties. + + Returns: + (x_index, y_index, z_index) + + Raises: + ValueError: If any of x, y, z is missing. + """ + xi = yi = zi = -1 + for i, prop in enumerate(vertex_elem.properties): + if prop.name == 'x': + xi = i + elif prop.name == 'y': + yi = i + elif prop.name == 'z': + zi = i + + if xi < 0 or yi < 0 or zi < 0: + raise ValueError('Vertex element missing x, y, or z property') + return xi, yi, zi + + +def _read_ascii( + fh: IO[bytes], + elements: list[_Element], +) -> tuple[np.ndarray, list[list[int]]]: + """Read ASCII PLY data after the header. + + Returns: + (vertices, faces) where vertices is (N, 3) float32 + and faces is a list of index lists (variable length). + """ + vertex_elem, face_elem = _find_elements(elements) + xi, yi, zi = _find_xyz_indices(vertex_elem) + + # Read all elements in order + vertices = np.empty((vertex_elem.count, 3), dtype=np.float32) + faces: list[list[int]] = [] + + for elem in elements: + for row_idx in range(elem.count): + raw = fh.readline() + if not raw: + raise ValueError( + f'Unexpected EOF reading {elem.name} row {row_idx}' + ) + line = raw.decode('ascii', errors='replace') + parts = line.split() + + if elem is vertex_elem: + vertices[row_idx] = [ + float(parts[xi]), + float(parts[yi]), + float(parts[zi]), + ] + elif elem is face_elem: + # First value is the vertex count, + # followed by vertex indices + n = int(parts[0]) + indices = [int(parts[j + 1]) for j in range(n)] + faces.append(indices) + # Other elements: skip (already consumed) + + return vertices, faces + + +def _read_binary_vertices( + raw: bytes, + vertex_elem: _Element, + endian: str, + xi: int, + yi: int, + zi: int, +) -> np.ndarray: + """Unpack binary vertex data into (N, 3) float32.""" + vertex_fmt = endian + vertex_size = 0 + for prop in vertex_elem.properties: + fmt_char, _, size = _PLY_TYPES[prop.type_name] + vertex_fmt += fmt_char + vertex_size += size + + n_verts = vertex_elem.count + vertices = np.empty((n_verts, 3), dtype=np.float32) + for i in range(n_verts): + vals = struct.unpack_from(vertex_fmt, raw, i * vertex_size) + vertices[i] = [vals[xi], vals[yi], vals[zi]] + return vertices + + +def _read_binary_faces( + fh: IO[bytes], + face_elem: _Element, + endian: str, +) -> list[list[int]]: + """Read binary face data.""" + list_prop = None + for prop in face_elem.properties: + if prop.is_list: + list_prop = prop + break + if list_prop is None: + raise ValueError('Face element has no list property') + + count_fmt_char, _, count_size = _PLY_TYPES[list_prop.count_type] + idx_fmt_char, _, idx_size = _PLY_TYPES[list_prop.item_type] + count_fmt = endian + count_fmt_char + + faces: list[list[int]] = [] + for _ in range(face_elem.count): + count_raw = fh.read(count_size) + if len(count_raw) != count_size: + raise ValueError('Unexpected EOF reading face') + n = struct.unpack(count_fmt, count_raw)[0] + idx_raw = fh.read(n * idx_size) + if len(idx_raw) != n * idx_size: + raise ValueError('Unexpected EOF reading face indices') + indices = list(struct.unpack(endian + idx_fmt_char * n, idx_raw)) + faces.append(indices) + return faces + + +def _skip_binary_elements( + fh: IO[bytes], + elements: list[_Element], + vertex_elem: _Element, + face_elem: _Element, +) -> None: + """Skip binary elements between vertex and face.""" + found_vertex = False + for elem in elements: + if elem is vertex_elem: + found_vertex = True + continue + if elem is face_elem: + break + if not found_vertex: + continue + row_size = 0 + for prop in elem.properties: + if prop.is_list: + raise ValueError( + f'Cannot skip element with list properties: {elem.name}' + ) + _, _, size = _PLY_TYPES[prop.type_name] + row_size += size + fh.read(elem.count * row_size) + + +def _read_binary( + fh: IO[bytes], + elements: list[_Element], + format_str: str, +) -> tuple[np.ndarray, list[list[int]]]: + """Read binary PLY data after the header. + + Returns: + (vertices, faces) where vertices is (N, 3) + float32 and faces is a list of index lists. + """ + endian = '<' if format_str == 'binary_little_endian' else '>' + vertex_elem, face_elem = _find_elements(elements) + xi, yi, zi = _find_xyz_indices(vertex_elem) + + # Compute vertex row size for bulk read. + vertex_size = sum( + _PLY_TYPES[p.type_name][2] for p in vertex_elem.properties + ) + n_verts = vertex_elem.count + raw = fh.read(n_verts * vertex_size) + if len(raw) != n_verts * vertex_size: + raise ValueError('Unexpected EOF reading vertex data') + + vertices = _read_binary_vertices(raw, vertex_elem, endian, xi, yi, zi) + _skip_binary_elements(fh, elements, vertex_elem, face_elem) + faces = _read_binary_faces(fh, face_elem, endian) + return vertices, faces + + +def _triangulate( + faces: list[list[int]], +) -> list[tuple[int, int, int]]: + """Fan-triangulate polygon faces into triangles. + + Each face with N vertices is split into N-2 triangles + using fan triangulation from vertex 0. + + Returns: + List of (i, j, k) triangle index tuples. + """ + triangles: list[tuple[int, int, int]] = [] + for face in faces: + if len(face) < 3: + continue + v0 = face[0] + triangles.extend( + (v0, face[i], face[i + 1]) for i in range(1, len(face) - 1) + ) + return triangles + + +def _build_mesh_data( + vertices: np.ndarray, + triangles: list[tuple[int, int, int]], + mesh_dtype: np.dtype, # type: ignore[type-arg] +) -> np.ndarray: + """Build the structured numpy array for the mesh. + + Args: + vertices: (N, 3) float32 vertex positions. + triangles: List of (i, j, k) index tuples. + mesh_dtype: The mesh dtype (normals, vectors, attr). + + Returns: + Structured 1-D numpy array with the mesh dtype. + """ + count = len(triangles) + data = np.zeros(count, dtype=mesh_dtype) + for i, (a, b, c) in enumerate(triangles): + data['vectors'][i][0] = vertices[a] + data['vectors'][i][1] = vertices[b] + data['vectors'][i][2] = vertices[c] + return data + + +def _deduplicate_vertices( + data: np.ndarray, +) -> tuple[np.ndarray, list[tuple[int, int, int]]]: + """Extract unique vertices and face indices. + + Args: + data: Mesh.dtype structured array. + + Returns: + (vertices, faces) where vertices is (N, 3) + float32 and faces is a list of (i, j, k) tuples. + """ + all_verts = data['vectors'].reshape(-1, 3) + unique_verts, inverse = np.unique(all_verts, axis=0, return_inverse=True) + faces: list[tuple[int, int, int]] = [] + for i in range(len(data)): + base = i * 3 + faces.append( + ( + int(inverse[base]), + int(inverse[base + 1]), + int(inverse[base + 2]), + ) + ) + return unique_verts, faces + + +def write_ply( + fh: IO[bytes], + data: np.ndarray, + name: str = '', + mode: str = 'binary_little_endian', +) -> None: + """Write mesh data to PLY format. + + Args: + fh: Binary file handle. + data: Mesh.dtype structured array. + name: Optional object name. + mode: 'ascii', 'binary_little_endian', or + 'binary_big_endian'. + """ + valid_modes = ( + 'ascii', + 'binary_little_endian', + 'binary_big_endian', + ) + if mode not in valid_modes: + raise ValueError( + f'Unknown PLY mode {mode!r}, expected one of {valid_modes}' + ) + + vertices, faces = _deduplicate_vertices(data) + + lines = [ + 'ply', + f'format {mode} 1.0', + ] + if name: + lines.append(f'obj_info {name}') + lines.extend( + [ + f'element vertex {len(vertices)}', + 'property float x', + 'property float y', + 'property float z', + f'element face {len(faces)}', + 'property list uchar int vertex_indices', + 'end_header', + ] + ) + header = '\n'.join(lines) + '\n' + fh.write(header.encode('ascii')) + + if mode == 'ascii': + for v in vertices: + fh.write(f'{v[0]} {v[1]} {v[2]}\n'.encode('ascii')) + for face in faces: + fh.write(f'3 {face[0]} {face[1]} {face[2]}\n'.encode('ascii')) + else: + endian = '<' if mode == 'binary_little_endian' else '>' + for v in vertices: + fh.write( + struct.pack( + f'{endian}fff', + float(v[0]), + float(v[1]), + float(v[2]), + ) + ) + for face in faces: + fh.write(struct.pack('B', 3)) + fh.write( + struct.pack( + f'{endian}iii', + face[0], + face[1], + face[2], + ) + ) + + +def read_ply( + fh: IO[bytes], + mesh_dtype: np.dtype, # type: ignore[type-arg] +) -> tuple[np.ndarray, str]: + """Read a PLY file and return mesh data. + + Args: + fh: Open binary file handle positioned at the + start of the PLY file. + mesh_dtype: The structured dtype for the mesh + (normals, vectors, attr). + + Returns: + (data, name) where data is a structured numpy + array and name is the object name from the header. + + Raises: + ValueError: If the file is not a valid PLY file + or uses an unsupported binary format. + """ + format_str, elements, obj_name = _parse_header(fh) + + if format_str == 'ascii': + vertices, faces = _read_ascii(fh, elements) + elif format_str in ( + 'binary_little_endian', + 'binary_big_endian', + ): + vertices, faces = _read_binary(fh, elements, format_str) + else: + raise ValueError(f'Unknown PLY format: {format_str!r}') + + triangles = _triangulate(faces) + data = _build_mesh_data(vertices, triangles, mesh_dtype) + return data, obj_name diff --git a/stl/stl.py b/stl/stl.py index 948a143..6809580 100644 --- a/stl/stl.py +++ b/stl/stl.py @@ -9,7 +9,6 @@ IO, TYPE_CHECKING, Any, - Final, Literal as L, # noqa: N817 cast, ) @@ -21,17 +20,12 @@ __about__ as metadata, base, ) +from ._compat import ( + ascii_read as _ascii_read, + ascii_write as _ascii_write, +) from .utils import b -# NOTE: This is needed because pyright will otherwise complain about -# the `# type: ignore[assignment]` below. -# pyright: reportUnnecessaryTypeIgnoreComment=false - -try: - from . import _speedups -except ImportError: # pragma: no cover - _speedups = None # type: ignore[assignment] - if TYPE_CHECKING: from typing import Protocol, type_check_only @@ -48,6 +42,13 @@ def tell(self) -> int: ... class Mode(enum.IntEnum): + """STL file format mode for loading and saving. + + Use :attr:`AUTOMATIC` for auto-detection (default), + :attr:`ASCII` to force ASCII format, or + :attr:`BINARY` to force binary format. + """ + #: Automatically detect whether the output is a TTY, if so, write ASCII #: otherwise write BINARY AUTOMATIC = 0 @@ -58,20 +59,20 @@ class Mode(enum.IntEnum): # For backwards compatibility, leave the original references -AUTOMATIC: Final[L[Mode.AUTOMATIC]] = Mode.AUTOMATIC -ASCII: Final[L[Mode.ASCII]] = Mode.ASCII -BINARY: Final[L[Mode.BINARY]] = Mode.BINARY +AUTOMATIC: L[Mode.AUTOMATIC] = Mode.AUTOMATIC +ASCII: L[Mode.ASCII] = Mode.ASCII +BINARY: L[Mode.BINARY] = Mode.BINARY #: Amount of bytes to read while using buffered reading -BUFFER_SIZE: Final[L[4096]] = 4096 +BUFFER_SIZE: int = 4096 #: The amount of bytes in the header field -HEADER_SIZE: Final[L[80]] = 80 +HEADER_SIZE: int = 80 #: The amount of bytes in the count field -COUNT_SIZE: Final[L[4]] = 4 +COUNT_SIZE: int = 4 #: The maximum amount of triangles we can read from binary files -MAX_COUNT: Final[float] = 1e8 +MAX_COUNT: float = 1e8 #: The header format, can be safely monkeypatched. Limited to 80 characters -HEADER_FORMAT: Final[str] = '{package_name} ({version}) {now} {name}' +HEADER_FORMAT: str = '{package_name} ({version}) {now} {name}' class BaseStl(base.BaseMesh): @@ -82,12 +83,21 @@ def load( mode: Mode = AUTOMATIC, speedups: bool = True, ) -> 'tuple[bytes, _data_1d] | Any': - """Load Mesh from STL file + """Load mesh data from an open STL file handle. + + Auto-detects binary vs ASCII format unless + ``mode`` is explicitly set. - Automatically detects binary versus ascii STL files. + Args: + fh: Open binary file handle. + mode: Force a specific format or use + :attr:`Mode.AUTOMATIC` (default). + speedups: Use Cython speedups for ASCII + parsing. Defaults to True. - :param file fh: The file handle to open - :param int mode: Automatically detect the filetype or force binary + Returns: + A (name, data) tuple, or None if the file + is empty. """ header = fh.read(HEADER_SIZE) if not header: @@ -280,10 +290,8 @@ def _load_ascii( fh.fileno() except io.UnsupportedOperation: speedups = False - # The speedups module is covered by travis but it can't be tested in - # all environments, this makes coverage checks easier - if _speedups is not None and speedups: # type: ignore[redundant-expr] # pragma: no cover - return _speedups.ascii_read(fh, header) + if _ascii_read is not None and speedups: + return _ascii_read(fh, header) else: iterator = cls._ascii_reader(fh, header) name = cast('bytes', next(iterator)) @@ -296,15 +304,35 @@ def save( # noqa: C901 mode: 'Mode | int' = AUTOMATIC, update_normals: bool = True, ) -> None: - """Save the STL to a (binary) file - - If mode is :py:data:`AUTOMATIC` an :py:data:`ASCII` file will be - written if the output is a TTY and a :py:data:`BINARY` file otherwise. - - :param str filename: The file to load - :param file fh: The file handle to open - :param int mode: The mode to write, default is :py:data:`AUTOMATIC`. - :param bool update_normals: Whether to update the normals + """Save the mesh to an STL file. + + If mode is :attr:`Mode.AUTOMATIC`, writes binary + unless the output is a TTY. + + Args: + filename: Output file path. Required even when + ``fh`` is provided (used for STL header). + fh: Optional pre-opened binary file handle. + mode: Output format. Defaults to + :attr:`Mode.AUTOMATIC`. + update_normals: Whether to recalculate normals + before saving. Defaults to True. + + Raises: + TypeError: If ``fh`` is a text-mode handle. + + Example: + >>> import numpy as np + >>> from stl import mesh + >>> data = np.zeros(1, dtype=mesh.Mesh.dtype) + >>> data['vectors'][0] = [[0, 0, 0], [1, 0, 0], [0, 1, 0]] + >>> m = mesh.Mesh(data, remove_empty_areas=False) + >>> m.save('/tmp/_numpy_stl_test.stl') + + Warning: + Even for ASCII output, the file handle must be + opened in binary mode (``'wb'``). A text-mode + handle raises ``TypeError``. """ assert filename, 'Filename is required for the STL headers' if update_normals: @@ -359,8 +387,8 @@ def _write_ascii(self, fh: IO[bytes], name: '_Name') -> None: except io.UnsupportedOperation: speedups = False - if _speedups is not None and speedups: # type: ignore[redundant-expr] # pragma: no cover - _speedups.ascii_write(fh, b(name), self.data) + if _ascii_write is not None and speedups: + _ascii_write(fh, b(name), self.data) else: def p(s: '_Name', file: 'SupportsWrite[bytes]') -> None: @@ -399,6 +427,14 @@ def p(s: '_Name', file: 'SupportsWrite[bytes]') -> None: p(b'endsolid ' + b(name), file=fh) def get_header(self, name: '_Name') -> str: + """Build the 80-byte binary STL header string. + + Args: + name: Solid name to embed in the header. + + Returns: + Header string truncated to 80 bytes. + """ # Format the header header: str = HEADER_FORMAT.format( package_name=metadata.__package_name__, @@ -450,13 +486,40 @@ def from_file( speedups: bool = True, **kwargs: Any, ) -> 'Self': - """Load a mesh from a STL file - - :param str filename: The file to load - :param bool calculate_normals: Whether to update the normals - :param file fh: The file handle to open - :param dict kwargs: The same as for :py:class:`stl.mesh.Mesh` - + """Load a mesh from an STL file. + + Reads binary or ASCII STL files. Format is + auto-detected unless ``mode`` is explicitly set. + + Args: + filename: Path to the STL file. + calculate_normals: Whether to recalculate + normals after loading. Defaults to True. + fh: Optional pre-opened binary file handle. + If provided, ``filename`` is used only + for the mesh name. + mode: Force ASCII or BINARY loading, or + AUTOMATIC detection (default). + speedups: Use Cython speedups for ASCII + parsing when available. Defaults to True. + **kwargs: Additional arguments passed to + the Mesh constructor. + + Returns: + A new Mesh instance containing the loaded data. + + Example: + >>> from stl import mesh + >>> m = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl') + >>> len(m.data) > 0 + True + + Note: + When ``speedups`` is True and the speedups + package is installed, ASCII parsing uses a + fast C implementation. Speedups are + automatically disabled for non-seekable + streams (e.g., stdin). """ if fh: name, data = cls.load(fh, mode=mode, speedups=speedups) @@ -479,15 +542,37 @@ def from_multi_file( speedups: bool = True, **kwargs: Any, ) -> Generator['Self', None, None]: - """Load multiple meshes from a STL file - - Note: mode is hardcoded to ascii since binary stl files do not support - the multi format - - :param str filename: The file to load - :param bool calculate_normals: Whether to update the normals - :param file fh: The file handle to open - :param dict kwargs: The same as for :py:class:`stl.mesh.Mesh` + """Load multiple solids from a single STL file. + + Yields one Mesh per ``solid`` block found. + + Args: + filename: Path to the STL file. + calculate_normals: Whether to recalculate + normals. Defaults to True. + fh: Optional pre-opened binary file handle. + mode: Format mode. Defaults to + :attr:`Mode.AUTOMATIC`. + speedups: Use Cython speedups when available. + **kwargs: Additional arguments passed to + the Mesh constructor. + + Yields: + Mesh instances, one per solid block. + + Example: + >>> from stl import mesh + >>> # Single-solid file yields one mesh + >>> solids = list( + ... mesh.Mesh.from_multi_file('tests/stl_ascii/HalfDonut.stl') + ... ) + >>> len(solids) >= 1 + True + + Note: + Multi-solid loading only works with ASCII + STL files. Binary STL files always contain + a single solid. """ if fh: close = False @@ -522,15 +607,25 @@ def from_files( speedups: bool = True, **kwargs: Any, ) -> 'Self': - """Load multiple meshes from STL files into a single mesh - - Note: mode is hardcoded to ascii since binary stl files do not support - the multi format - - :param list(str) filenames: The files to load - :param bool calculate_normals: Whether to update the normals - :param file fh: The file handle to open - :param dict kwargs: The same as for :py:class:`stl.mesh.Mesh` + """Load and merge multiple STL files into one mesh. + + Args: + filenames: List of STL file paths. + calculate_normals: Whether to recalculate + normals. Defaults to True. + mode: Format mode for each file. + speedups: Use Cython speedups when available. + **kwargs: Additional arguments passed to + the Mesh constructor. + + Returns: + A single Mesh with data from all files. + + Example: + >>> from stl import mesh + >>> m = mesh.Mesh.from_files(['tests/stl_binary/HalfDonut.stl']) + >>> len(m.data) > 0 + True """ meshes = [ cls.from_file( @@ -554,6 +649,30 @@ def from_3mf_file( calculate_normals: bool = True, **kwargs: object, ) -> Generator['Self', None, None]: + """Load meshes from a 3MF file (read-only). + + Parses the 3MF ZIP archive and yields one Mesh + per ```` element found. + + Args: + filename: Path to the .3mf file. + calculate_normals: Whether to recalculate + normals. Defaults to True. + **kwargs: Additional arguments. + + Yields: + Mesh instances, one per 3MF mesh element. + + Example: + >>> from stl import mesh + >>> meshes = list(mesh.Mesh.from_3mf_file('tests/3mf/Moon.3mf')) + >>> len(meshes) > 0 + True + + Note: + 3MF support is experimental and read-only. + Not all 3MF features are supported. + """ with zipfile.ZipFile(filename) as zip: with zip.open('_rels/.rels') as rels_fh: model = None @@ -605,6 +724,92 @@ def from_3mf_file( # pyrefly: ignore[invalid-yield] yield mesh + @classmethod + def from_ply_file( + cls, + filename: str, + calculate_normals: bool = True, + fh: 'IO[bytes] | None' = None, + **kwargs: Any, + ) -> 'Self': + """Load a mesh from a PLY file. + + Supports ASCII and binary PLY formats + (little-endian and big-endian). + + Args: + filename: Path to the .ply file. + calculate_normals: Whether to recalculate + normals. Defaults to True. + fh: Optional pre-opened binary file handle. + **kwargs: Additional arguments passed to + the Mesh constructor. + + Returns: + A Mesh instance. + + Example: + >>> from stl import mesh + >>> m = mesh.Mesh.from_ply_file('tests/ply_ascii/Cube.ply') + >>> len(m.data) == 12 + True + """ + from .ply import read_ply + + if fh: + data, name = read_ply(fh, cls.dtype) + else: + with open(filename, 'rb') as fh: + data, name = read_ply(fh, cls.dtype) + + # pyrefly: ignore[bad-return] + return cls( + data, + calculate_normals, + name=name, + **kwargs, + ) + + def save_ply( + self, + filename: str, + fh: 'IO[bytes] | None' = None, + mode: str = 'binary_little_endian', + update_normals: bool = True, + ) -> None: + """Save the mesh to a PLY file. + + Args: + filename: Output file path. + fh: Optional pre-opened binary file handle. + mode: PLY format. One of ``'ascii'``, + ``'binary_little_endian'`` (default), + ``'binary_big_endian'``. + update_normals: Whether to recalculate normals + before saving. Defaults to True. + + Example: + >>> from stl import mesh + >>> m = mesh.Mesh.from_file('tests/stl_binary/HalfDonut.stl') + >>> m.save_ply('/tmp/_numpy_stl_test.ply') + """ + from .ply import write_ply + + if update_normals: + self.update_normals() + + name = '' + if isinstance(self.name, bytes): + name = self.name.decode('ascii', errors='replace') + elif isinstance(self.name, str): + name = self.name + + if fh: + write_ply(fh, self.data, name=name, mode=mode) + else: + with open(filename, 'wb') as fh: + write_ply(fh, self.data, name=name, mode=mode) + if TYPE_CHECKING: diff --git a/stl/utils.py b/stl/utils.py index 85721a4..9c837be 100644 --- a/stl/utils.py +++ b/stl/utils.py @@ -3,6 +3,17 @@ def b( encoding: str = 'ascii', errors: str = 'replace', ) -> bytes: # pragma: no cover + """Encode a string to bytes, passing bytes through. + + Args: + s: String or bytes input. + encoding: Encoding to use. Defaults to ``'ascii'``. + errors: Error handling strategy. Defaults to + ``'replace'``. + + Returns: + Encoded bytes. + """ if isinstance(s, str): return bytes(s, encoding, errors) else: diff --git a/tests/docs_examples/__init__.py b/tests/docs_examples/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/docs_examples/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/docs_examples/_docs_examples.py b/tests/docs_examples/_docs_examples.py new file mode 100644 index 0000000..b210ed9 --- /dev/null +++ b/tests/docs_examples/_docs_examples.py @@ -0,0 +1,525 @@ +from __future__ import annotations + +import contextlib +import io +import os +import shlex +import shutil +import subprocess +import sys +import textwrap +import traceback +import venv +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +README_PATH: Final = PROJECT_ROOT / 'README.md' +DOCS_ROOT: Final = PROJECT_ROOT / 'docs' +SUPPORTED_LANGUAGES: Final = frozenset({'bash', 'python'}) +CLI_COMMANDS: Final = frozenset({'stl', 'stl2ascii', 'stl2bin'}) +INSTALL_REWRITES: Final = { + 'pip install numpy-stl': '.', + 'pip install numpy-stl[fast]': '.[fast]', +} +SANDBOX_FIXTURES: Final = { + 'binary_model.stl': ( + PROJECT_ROOT / 'tests' / 'stl_binary' / 'HalfDonut.stl' + ), + 'closed_model.stl': PROJECT_ROOT / 'tests' / 'stl_binary' / 'Cube.stl', + 'dragon_vrip.ply': PROJECT_ROOT / 'tests' / 'ply_ascii' / 'Cube.ply', + 'input.stl': PROJECT_ROOT / 'tests' / 'stl_ascii' / 'HalfDonut.stl', + 'model.3mf': PROJECT_ROOT / 'tests' / '3mf' / 'Moon.3mf', + 'model.stl': PROJECT_ROOT / 'tests' / 'stl_ascii' / 'HalfDonut.stl', + 'part1.stl': PROJECT_ROOT / 'tests' / 'stl_ascii' / 'Cube.stl', + 'part2.stl': PROJECT_ROOT / 'tests' / 'stl_binary' / 'HalfDonut.stl', +} +MULTI_SOLID_SAMPLE: Final = textwrap.dedent( + """\ + solid first + facet normal 0 0 1 + outer loop + vertex 0 0 0 + vertex 1 0 0 + vertex 0 1 0 + endloop + endfacet + endsolid first + solid second + facet normal 0 0 1 + outer loop + vertex 0 0 1 + vertex 1 0 1 + vertex 0 1 1 + endloop + endfacet + endsolid second + """ +) + + +@dataclass(frozen=True) +class CodeSample: + source_path: Path + language: str + body: str + block_index: int + start_line: int + + @property + def display_path(self) -> str: + return self.source_path.relative_to(PROJECT_ROOT).as_posix() + + @property + def preview(self) -> str: + for line in self.body.splitlines(): + stripped = line.strip() + if stripped: + return stripped + + return '' + + +@dataclass(frozen=True) +class CommandResult: + command: str + stdout: str + stderr: str + returncode: int + + +class SampleExecutionError(AssertionError): + def __init__( + self, + sample: CodeSample, + *, + command: str, + stdout: str = '', + stderr: str = '', + details: str = '', + ) -> None: + self.sample = sample + self.command = command + self.stdout = stdout + self.stderr = stderr + self.details = details + super().__init__(format_execution_error(self)) + + +class InstallSandboxCache: + def __init__(self, base_dir: Path) -> None: + self.base_dir = base_dir + self._venvs: dict[str, Path] = {} + + def run(self, requirement: str) -> CommandResult: + venv_dir = self._venvs.get(requirement) + if venv_dir is None: + venv_name = 'fast' if requirement == '.[fast]' else 'base' + venv_dir = self.base_dir / venv_name + create_virtualenv(venv_dir) + self._venvs[requirement] = venv_dir + + python = venv_python(venv_dir) + env = os.environ.copy() + env.setdefault('PIP_DISABLE_PIP_VERSION_CHECK', '1') + uv = shutil.which('uv') + if uv is not None: + command = [ + uv, + 'pip', + 'install', + '--python', + str(python), + requirement, + ] + completed = subprocess.run( + command, + capture_output=True, + check=False, + cwd=PROJECT_ROOT, + env=env, + text=True, + ) + else: + command = [ + str(python), + '-m', + 'pip', + 'install', + '--disable-pip-version-check', + '--no-input', + requirement, + ] + completed = subprocess.run( + command, + capture_output=True, + check=False, + cwd=PROJECT_ROOT, + env=env, + text=True, + ) + + return CommandResult( + command=' '.join(command), + stdout=completed.stdout, + stderr=completed.stderr, + returncode=completed.returncode, + ) + + +def iter_doc_sources() -> tuple[Path, ...]: + return (README_PATH, *sorted(DOCS_ROOT.rglob('*.rst'))) + + +def extract_code_samples(path: Path) -> list[CodeSample]: + text = path.read_text() + if path.suffix == '.md': + return extract_markdown_samples(path, text) + if path.suffix == '.rst': + return extract_rst_code_blocks(path, text) + + msg = f'Unsupported docs source: {path}' + raise ValueError(msg) + + +def extract_markdown_samples(path: Path, text: str) -> list[CodeSample]: + samples: list[CodeSample] = [] + lines = text.splitlines() + block_index = 0 + index = 0 + + while index < len(lines): + line = lines[index] + if not line.startswith('```'): + index += 1 + continue + + language = line[3:].strip().split(maxsplit=1)[0].lower() + block_index += 1 + index += 1 + start_line = index + 1 + body: list[str] = [] + + while index < len(lines): + current_line = lines[index] + if current_line.startswith('```'): + break + + body.append(current_line) + index += 1 + + samples.append( + CodeSample( + source_path=path, + language=language, + body='\n'.join(body), + block_index=block_index, + start_line=start_line, + ) + ) + index += 1 + + return samples + + +def extract_rst_code_blocks(path: Path, text: str) -> list[CodeSample]: + samples: list[CodeSample] = [] + lines = text.splitlines() + block_index = 0 + index = 0 + + while index < len(lines): + line = lines[index] + if not line.startswith('.. code-block::'): + index += 1 + continue + + language = line.split('::', 1)[1].strip().split(maxsplit=1)[0].lower() + block_index += 1 + index += 1 + + block_start = find_rst_block_start(lines, index) + if block_start is None: + break + index = block_start + + block_indent = len(lines[index]) - len(lines[index].lstrip(' ')) + start_line = index + 1 + body: list[str] = [] + + while index < len(lines): + current_line = lines[index] + if not current_line.strip(): + body.append('') + index += 1 + continue + + current_indent = len(current_line) - len(current_line.lstrip(' ')) + if current_indent < block_indent: + break + + body.append(current_line[block_indent:]) + index += 1 + + while body and not body[-1]: + body.pop() + + samples.append( + CodeSample( + source_path=path, + language=language, + body='\n'.join(body), + block_index=block_index, + start_line=start_line, + ) + ) + + return samples + + +def seed_docs_sandbox(target_dir: Path) -> None: + for name, source in SANDBOX_FIXTURES.items(): + shutil.copyfile(source, target_dir / name) + + (target_dir / 'multi.stl').write_text(MULTI_SOLID_SAMPLE) + + +def build_python_namespace() -> dict[str, object]: + namespace: dict[str, object] = {'__name__': '__main__'} + prelude = textwrap.dedent( + """\ + import os + import warnings + + os.environ.setdefault('MPLBACKEND', 'Agg') + warnings.filterwarnings( + 'ignore', + message=( + 'FigureCanvasAgg is non-interactive, and thus ' + 'cannot be shown' + ), + ) + + try: + import matplotlib + matplotlib.use('Agg') + from matplotlib import pyplot as _docs_pyplot + except ImportError: + pass + else: + _docs_pyplot.show = lambda *args, **kwargs: None + """ + ) + exec(prelude, namespace, namespace) + return namespace + + +def execute_python_sample( + sample: CodeSample, + namespace: dict[str, object], + cwd: Path, +) -> None: + stdout = io.StringIO() + stderr = io.StringIO() + namespace['__file__'] = str(sample.source_path) + filename = f'{sample.display_path}:{sample.start_line}' + + try: + with working_directory(cwd): + with contextlib.redirect_stdout(stdout): + with contextlib.redirect_stderr(stderr): + compiled = compile(sample.body, filename, 'exec') + exec(compiled, namespace, namespace) + except Exception: + raise SampleExecutionError( + sample, + command='python', + stdout=stdout.getvalue(), + stderr=stderr.getvalue(), + details=traceback.format_exc(), + ) from None + + +def execute_bash_sample( + sample: CodeSample, + cwd: Path, + install_cache: InstallSandboxCache, +) -> None: + requirement = install_requirement(sample.body) + if requirement is not None: + result = install_cache.run(requirement) + if result.returncode != 0: + raise SampleExecutionError( + sample, + command=result.command, + stdout=result.stdout, + stderr=result.stderr, + ) + return + + env = os.environ.copy() + env.setdefault('MPLBACKEND', 'Agg') + completed = subprocess.run( + ['bash', '-euxo', 'pipefail', '-c', sample.body], + capture_output=True, + check=False, + cwd=cwd, + env=env, + text=True, + ) + + if completed.returncode != 0: + raise SampleExecutionError( + sample, + command=sample.body, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + missing_outputs = [ + output + for output in expected_cli_outputs(sample.body) + if output != '-' and not (cwd / output).exists() + ] + if missing_outputs: + outputs = ', '.join(missing_outputs) + raise SampleExecutionError( + sample, + command=sample.body, + stdout=completed.stdout, + stderr=completed.stderr, + details=f'Expected CLI outputs were not created: {outputs}', + ) + + +def expected_cli_outputs(script: str) -> list[str]: + outputs: list[str] = [] + + for line in script.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + + argv = shlex.split(stripped) + if len(argv) < 3 or argv[0] not in CLI_COMMANDS: + continue + + outputs.append(argv[2]) + + return outputs + + +def install_requirement(script: str) -> str | None: + commands = [ + line.strip() + for line in script.splitlines() + if line.strip() and not line.strip().startswith('#') + ] + if len(commands) != 1: + return None + + return INSTALL_REWRITES.get(commands[0]) + + +def format_execution_error(error: SampleExecutionError) -> str: + sample = error.sample + parts = [ + ( + 'Docs example failed: ' + f'{sample.display_path}:{sample.start_line} ' + f'(block {sample.block_index}, {sample.language})' + ), + f'Preview: {sample.preview}', + 'Code:', + textwrap.indent(sample.body, ' '), + 'Command:', + textwrap.indent(error.command, ' '), + ] + if error.stdout: + parts.extend( + [ + 'Stdout:', + textwrap.indent(error.stdout.rstrip(), ' '), + ] + ) + if error.stderr: + parts.extend( + [ + 'Stderr:', + textwrap.indent(error.stderr.rstrip(), ' '), + ] + ) + if error.details: + parts.extend( + [ + 'Details:', + textwrap.indent(error.details.rstrip(), ' '), + ] + ) + + return '\n'.join(parts) + + +def runnable_samples(path: Path) -> list[CodeSample]: + return [ + sample + for sample in extract_code_samples(path) + if sample.language in SUPPORTED_LANGUAGES + ] + + +def venv_python(venv_dir: Path) -> Path: + scripts_dir = 'Scripts' if os.name == 'nt' else 'bin' + executable = 'python.exe' if os.name == 'nt' else 'python' + return venv_dir / scripts_dir / executable + + +def create_virtualenv(venv_dir: Path) -> None: + uv = shutil.which('uv') + if uv is not None: + completed = subprocess.run( + [uv, 'venv', str(venv_dir), '--python', sys.executable], + capture_output=True, + check=False, + cwd=PROJECT_ROOT, + text=True, + ) + if completed.returncode == 0: + return + + msg = '\n'.join( + [ + f'Failed to create virtualenv with uv: {venv_dir}', + completed.stdout.rstrip(), + completed.stderr.rstrip(), + ] + ) + raise RuntimeError(msg) + + venv.EnvBuilder(with_pip=True).create(venv_dir) + + +def find_rst_block_start(lines: list[str], start_index: int) -> int | None: + index = start_index + while index < len(lines): + current_line = lines[index] + if not current_line.strip(): + index += 1 + continue + if current_line.lstrip().startswith(':'): + index += 1 + continue + return index + + return None + + +@contextlib.contextmanager +def working_directory(path: Path): + original_cwd = Path.cwd() + os.chdir(path) + try: + yield + finally: + os.chdir(original_cwd) diff --git a/tests/docs_examples/test_docs_examples.py b/tests/docs_examples/test_docs_examples.py new file mode 100644 index 0000000..76e944c --- /dev/null +++ b/tests/docs_examples/test_docs_examples.py @@ -0,0 +1,52 @@ +import os + +import pytest + +from ._docs_examples import ( + PROJECT_ROOT, + InstallSandboxCache, + build_python_namespace, + execute_bash_sample, + execute_python_sample, + iter_doc_sources, + runnable_samples, + seed_docs_sandbox, +) + +DOC_SOURCES = tuple( + path for path in iter_doc_sources() if runnable_samples(path) +) + +pytestmark = pytest.mark.skipif( + os.environ.get('NUMPY_STL_RUN_DOCS_EXAMPLES') != '1', + reason='Run docs example tests via tox -e docs-examples', +) + + +@pytest.fixture(scope='session') +def install_cache(tmp_path_factory): + cache_dir = tmp_path_factory.mktemp('docs-example-installs') + return InstallSandboxCache(cache_dir) + + +@pytest.mark.parametrize( + 'source_path', + DOC_SOURCES, + ids=lambda path: path.relative_to(PROJECT_ROOT).as_posix(), +) +def test_docs_code_samples_execute( + source_path, + tmp_path, + install_cache, + speedups, +): + sandbox = tmp_path / 'sandbox' + sandbox.mkdir() + seed_docs_sandbox(sandbox) + namespace = build_python_namespace() + + for sample in runnable_samples(source_path): + if sample.language == 'python': + execute_python_sample(sample, namespace, sandbox) + else: + execute_bash_sample(sample, sandbox, install_cache) diff --git a/tests/docs_examples/test_extractor.py b/tests/docs_examples/test_extractor.py new file mode 100644 index 0000000..97f41bc --- /dev/null +++ b/tests/docs_examples/test_extractor.py @@ -0,0 +1,58 @@ +import os +from pathlib import Path + +import pytest + +from ._docs_examples import extract_markdown_samples, extract_rst_code_blocks + +pytestmark = pytest.mark.skipif( + os.environ.get('NUMPY_STL_RUN_DOCS_EXAMPLES') != '1', + reason='Run docs example tests via tox -e docs-examples', +) + + +def test_extract_markdown_samples_tracks_language_and_line_numbers(speedups): + source = Path('/tmp/README.md') + text = ( + '# Heading\n' + '\n' + '```python\n' + "print('hello')\n" + '```\n' + '\n' + '```bash\n' + 'echo ok\n' + '```\n' + ) + + samples = extract_markdown_samples(source, text) + + assert [sample.language for sample in samples] == ['python', 'bash'] + assert [sample.start_line for sample in samples] == [4, 8] + assert samples[0].body == "print('hello')" + assert samples[1].body == 'echo ok' + + +def test_extract_rst_code_blocks_preserves_blank_lines_and_options(speedups): + source = Path('/tmp/guide.rst') + text = ( + 'Section\n' + '=======\n' + '\n' + '.. code-block:: python\n' + ' :caption: Example\n' + '\n' + ' import math\n' + '\n' + ' print(math.pi)\n' + '\n' + 'Next section\n' + '------------\n' + ) + + samples = extract_rst_code_blocks(source, text) + + assert len(samples) == 1 + assert samples[0].language == 'python' + assert samples[0].start_line == 7 + assert samples[0].body == 'import math\n\nprint(math.pi)' diff --git a/tests/generate_ply_fixtures.py b/tests/generate_ply_fixtures.py new file mode 100644 index 0000000..f724a8b --- /dev/null +++ b/tests/generate_ply_fixtures.py @@ -0,0 +1,67 @@ +"""Generate binary PLY test fixtures from the ASCII cube definition.""" + +import pathlib +import struct + +VERTICES = [ + (-1.0, -1.0, -1.0), + (1.0, -1.0, -1.0), + (1.0, 1.0, -1.0), + (-1.0, 1.0, -1.0), + (-1.0, -1.0, 1.0), + (1.0, -1.0, 1.0), + (1.0, 1.0, 1.0), + (-1.0, 1.0, 1.0), +] + +FACES = [ + (0, 3, 1), + (1, 3, 2), + (0, 4, 7), + (0, 7, 3), + (4, 5, 6), + (4, 6, 7), + (5, 1, 2), + (5, 2, 6), + (2, 3, 6), + (3, 7, 6), + (0, 1, 5), + (0, 5, 4), +] + +BASE = pathlib.Path(__file__).parent + + +def write_binary_ply(path: pathlib.Path, endian: str) -> None: + fmt_char = '<' if endian == 'little' else '>' + format_name = ( + 'binary_little_endian' if endian == 'little' else 'binary_big_endian' + ) + + header = ( + f'ply\n' + f'format {format_name} 1.0\n' + f'comment Cube test fixture\n' + f'element vertex {len(VERTICES)}\n' + f'property float x\n' + f'property float y\n' + f'property float z\n' + f'element face {len(FACES)}\n' + f'property list uchar int vertex_indices\n' + f'end_header\n' + ).encode('ascii') + + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, 'wb') as fh: + fh.write(header) + for v in VERTICES: + fh.write(struct.pack(f'{fmt_char}fff', *v)) + for face in FACES: + fh.write(struct.pack('B', len(face))) + for idx in face: + fh.write(struct.pack(f'{fmt_char}i', idx)) + + +write_binary_ply(BASE / 'ply_binary' / 'Cube.ply', 'little') +write_binary_ply(BASE / 'ply_binary' / 'CubeBigEndian.ply', 'big') +print('Generated binary PLY fixtures') diff --git a/tests/ply_ascii/Cube.ply b/tests/ply_ascii/Cube.ply new file mode 100644 index 0000000..28a89d3 --- /dev/null +++ b/tests/ply_ascii/Cube.ply @@ -0,0 +1,30 @@ +ply +format ascii 1.0 +comment Cube test fixture +element vertex 8 +property float x +property float y +property float z +element face 12 +property list uchar int vertex_indices +end_header +-1 -1 -1 +1 -1 -1 +1 1 -1 +-1 1 -1 +-1 -1 1 +1 -1 1 +1 1 1 +-1 1 1 +3 0 3 1 +3 1 3 2 +3 0 4 7 +3 0 7 3 +3 4 5 6 +3 4 6 7 +3 5 1 2 +3 5 2 6 +3 2 3 6 +3 3 7 6 +3 0 1 5 +3 0 5 4 diff --git a/tests/ply_ascii/Quad.ply b/tests/ply_ascii/Quad.ply new file mode 100644 index 0000000..1ca48ff --- /dev/null +++ b/tests/ply_ascii/Quad.ply @@ -0,0 +1,24 @@ +ply +format ascii 1.0 +comment Cube with quad faces +element vertex 8 +property float x +property float y +property float z +element face 6 +property list uchar int vertex_indices +end_header +-1 -1 -1 +1 -1 -1 +1 1 -1 +-1 1 -1 +-1 -1 1 +1 -1 1 +1 1 1 +-1 1 1 +4 0 1 2 3 +4 4 7 6 5 +4 0 4 5 1 +4 2 6 7 3 +4 0 3 7 4 +4 1 5 6 2 diff --git a/tests/ply_binary/Cube.ply b/tests/ply_binary/Cube.ply new file mode 100644 index 0000000..86f240f Binary files /dev/null and b/tests/ply_binary/Cube.ply differ diff --git a/tests/ply_binary/CubeBigEndian.ply b/tests/ply_binary/CubeBigEndian.ply new file mode 100644 index 0000000..5c3784d Binary files /dev/null and b/tests/ply_binary/CubeBigEndian.ply differ diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index aa9d818..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -cov-core -coverage -docutils -execnet -numpy -cython -pep8 -py -pyflakes -pytest -pytest-cache -pytest-cov -python-utils -Sphinx -flake8 -wheel - -mypy -basedpyright -pyrefly diff --git a/tests/test_about.py b/tests/test_about.py new file mode 100644 index 0000000..12226c0 --- /dev/null +++ b/tests/test_about.py @@ -0,0 +1,34 @@ +import importlib +import importlib.metadata + + +def test_version_fallback_on_import_error(monkeypatch, speedups): + """When importlib.metadata.version raises, __version__ falls back.""" + + # Monkeypatch version() to raise PackageNotFoundError + def _raise(name): + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, 'version', _raise) + + # Force re-import of __about__ to trigger the except branch + import stl.__about__ + + importlib.reload(stl.__about__) + assert stl.__about__.__version__ == '0.0.0' + + +def test_version_normal(speedups): + """Under normal conditions, __version__ is a non-empty string.""" + # Reload to ensure fresh state (previous test may have left + # the module with the fallback value) + import stl.__about__ + + importlib.reload(stl.__about__) + version = stl.__about__.__version__ + + assert isinstance(version, str) + assert version != '' + # The version should match the installed package version + expected = importlib.metadata.version('numpy-stl') + assert version == expected diff --git a/tests/test_ascii.py b/tests/test_ascii.py index c703037..33f9272 100644 --- a/tests/test_ascii.py +++ b/tests/test_ascii.py @@ -9,9 +9,9 @@ import numpy as np import pytest +from stl.utils import b from stl import Mode, mesh -from stl.utils import b FILES_PATH = pathlib.Path(__file__).parent / 'stl_tests' @@ -131,10 +131,6 @@ def test_use_with_qt_with_custom_locale_decimal_delimeter(speedups): if not speedups: pytest.skip('Only makes sense with speedups') - venv = os.environ.get('VIRTUAL_ENV', '') - if sys.version_info[:2] == (3, 6) and venv.startswith('/home/travis/'): - pytest.skip('PySide2/PyQt5 tests are broken on Travis Python 3.6') - try: from PySide2 import QtWidgets except ImportError: diff --git a/tests/test_commandline.py b/tests/test_commandline.py index 3b9f332..6ad4eeb 100644 --- a/tests/test_commandline.py +++ b/tests/test_commandline.py @@ -42,7 +42,7 @@ def test_ascii(binary_file, tmpdir, speedups): try: sys.argv[:] = [ 'stl', - '-s' if not speedups else '', + *(['-s'] if not speedups else []), binary_file, str(tmpdir.join('ascii.stl')), ] @@ -57,7 +57,7 @@ def test_binary(ascii_file, tmpdir, speedups): try: sys.argv[:] = [ 'stl', - '-s' if not speedups else '', + *(['-s'] if not speedups else []), ascii_file, str(tmpdir.join('binary.stl')), ] diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..abc5a63 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,65 @@ +import importlib +import importlib.machinery +import sys +import types + +from stl._compat import ascii_read, ascii_write, has_speedups + + +def test_has_speedups_returns_bool(speedups): + result = has_speedups() + assert isinstance(result, bool) + + +def test_ascii_read_type(speedups): + # ascii_read is either None (no speedups) or callable + if has_speedups(): + assert callable(ascii_read) + else: + assert ascii_read is None + + +def test_ascii_write_type(speedups): + # ascii_write is either None (no speedups) or callable + if has_speedups(): + assert callable(ascii_write) + else: + assert ascii_write is None + + +def test_has_speedups_consistent_with_exports(speedups): + if has_speedups(): + assert ascii_read is not None + assert ascii_write is not None + else: + assert ascii_read is None + assert ascii_write is None + + +def test_import_error_fallback(): + """Verify _compat gracefully handles a broken speedups package.""" + fake = types.ModuleType('speedups') + fake.__path__ = [] + fake.__spec__ = importlib.machinery.ModuleSpec( + 'speedups', + None, + is_package=True, + ) + + saved_module = sys.modules.get('speedups') + saved_compat = sys.modules.pop('stl._compat', None) + try: + sys.modules['speedups'] = fake + compat = importlib.import_module('stl._compat') + assert compat.ascii_read is None + assert compat.ascii_write is None + assert compat.has_speedups() is False + finally: + if saved_module is not None: + sys.modules['speedups'] = saved_module + else: + sys.modules.pop('speedups', None) + if saved_compat is not None: + sys.modules['stl._compat'] = saved_compat + else: + sys.modules.pop('stl._compat', None) diff --git a/tests/test_mesh.py b/tests/test_mesh.py index 713fb61..b064b51 100644 --- a/tests/test_mesh.py +++ b/tests/test_mesh.py @@ -1,6 +1,5 @@ # type: ignore[reportAttributeAccessIssue] import numpy as np - from stl.base import BaseMesh, RemoveDuplicates from stl.mesh import Mesh @@ -179,19 +178,22 @@ def test_base_mesh(): # Check item 0 (contains v0, v1 and v2) assert ( - mesh[0] == np.array( + mesh[0] + == np.array( [1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0], dtype=np.float32 ) ).all() assert ( - mesh.vectors[0] == np.array( + mesh.vectors[0] + == np.array( [[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [0.0, 0.0, 0.0]], dtype=np.float32, ) ).all() assert (mesh.v0[0] == np.array([1.0, 1.0, 1.0], dtype=np.float32)).all() assert ( - mesh.points[0] == np.array( + mesh.points[0] + == np.array( [1.0, 1.0, 1.0, 2.0, 2.0, 2.0, 0.0, 0.0, 0.0], dtype=np.float32 ) ).all() @@ -199,7 +201,8 @@ def test_base_mesh(): mesh[0] = 3 assert ( - mesh[0] == np.array( + mesh[0] + == np.array( [3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0], dtype=np.float32 ) ).all() @@ -210,3 +213,19 @@ def test_base_mesh(): assert mesh.units.sum() == 0.0 mesh.v0[:] = mesh.v1[:] = mesh.v2[:] = 0 assert mesh.points.sum() == 0.0 + + +def test_mesh_identity_equality(): + data = np.zeros(2, dtype=Mesh.dtype) + data['vectors'][0] = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0]]) + + mesh_a = Mesh(data.copy(), remove_empty_areas=False) + mesh_b = Mesh(data.copy(), remove_empty_areas=False) + assert mesh_a != mesh_b + assert mesh_a == mesh_a + + lookup = {mesh_a: 'a', mesh_b: 'b'} + assert lookup[mesh_a] == 'a' + assert lookup[mesh_b] == 'b' + + assert mesh_a != 'not a mesh' diff --git a/tests/test_mesh_properties.py b/tests/test_mesh_properties.py index ef22386..a2add79 100644 --- a/tests/test_mesh_properties.py +++ b/tests/test_mesh_properties.py @@ -109,15 +109,16 @@ def test_mass_properties_for_half_donut_with_density( ) -@pytest.mark.parametrize('filename, expected_result', [ - ('Cube.stl', True), - ('HalfDonut.stl', False), - ('Moon.stl', False), - ('Star.stl', False), -]) -def test_is_convex( - binary_ascii_path, speedups, filename, expected_result -): +@pytest.mark.parametrize( + 'filename, expected_result', + [ + ('Cube.stl', True), + ('HalfDonut.stl', False), + ('Moon.stl', False), + ('Star.stl', False), + ], +) +def test_is_convex(binary_ascii_path, speedups, filename, expected_result): """Check the is_convex() method on various STL files.""" filepath = binary_ascii_path / filename mesh = stl.StlMesh(str(filepath), speedups=speedups) diff --git a/tests/test_ply.py b/tests/test_ply.py new file mode 100644 index 0000000..3f552f4 --- /dev/null +++ b/tests/test_ply.py @@ -0,0 +1,558 @@ +import io +import pathlib +import struct + +import numpy as np +import pytest + +from stl import ( + mesh, + ply, + stl as stl_module, +) + +PLY_ASCII_PATH = pathlib.Path(__file__).parent / 'ply_ascii' +PLY_BINARY_PATH = pathlib.Path(__file__).parent / 'ply_binary' + +CUBE_VERTICES = np.array( + [ + [-1, -1, -1], + [1, -1, -1], + [1, 1, -1], + [-1, 1, -1], + [-1, -1, 1], + [1, -1, 1], + [1, 1, 1], + [-1, 1, 1], + ], + dtype=np.float32, +) + +CUBE_FACES = [ + (0, 3, 1), + (1, 3, 2), + (0, 4, 7), + (0, 7, 3), + (4, 5, 6), + (4, 6, 7), + (5, 1, 2), + (5, 2, 6), + (2, 3, 6), + (3, 7, 6), + (0, 1, 5), + (0, 5, 4), +] + + +def _expected_vectors() -> np.ndarray: + """Build the expected (12, 3, 3) vectors array.""" + vectors = np.zeros((len(CUBE_FACES), 3, 3), dtype=np.float32) + for i, (a, b, c) in enumerate(CUBE_FACES): + vectors[i][0] = CUBE_VERTICES[a] + vectors[i][1] = CUBE_VERTICES[b] + vectors[i][2] = CUBE_VERTICES[c] + return vectors + + +def _make_vertex_element( + *property_names: str, + count: int = 1, +) -> ply._Element: + element = ply._Element('vertex', count) + for name in property_names: + element.properties.append(ply._Property(name, 'float')) + return element + + +def _make_face_element( + *, + count: int = 1, + include_list: bool = True, + scalar_prefix: bool = False, +) -> ply._Element: + element = ply._Element('face', count) + if scalar_prefix: + element.properties.append(ply._Property('material_index', 'uchar')) + if include_list: + element.properties.append( + ply._Property( + 'vertex_indices', + 'list', + is_list=True, + count_type='uchar', + item_type='int', + ) + ) + return element + + +def _triangle_mesh_data() -> np.ndarray: + data = np.zeros(1, dtype=mesh.Mesh.dtype) + data['vectors'][0] = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + ], + dtype=np.float32, + ) + return data + + +class TestReadAsciiPly: + def test_read_ascii_ply_face_count(self): + m = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + assert len(m.data) == 12 + + def test_read_ascii_ply_vectors(self): + m = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + expected = _expected_vectors() + np.testing.assert_array_almost_equal(m.vectors, expected) + + def test_read_ascii_ply_with_filehandle(self): + path = PLY_ASCII_PATH / 'Cube.ply' + with open(path, 'rb') as fh: + m = mesh.Mesh.from_ply_file(str(path), fh=fh) + assert len(m.data) == 12 + + +class TestReadBinaryPly: + def test_read_binary_le_face_count(self): + m = mesh.Mesh.from_ply_file(str(PLY_BINARY_PATH / 'Cube.ply')) + assert len(m.data) == 12 + + def test_read_binary_le_vectors(self): + m = mesh.Mesh.from_ply_file(str(PLY_BINARY_PATH / 'Cube.ply')) + expected = _expected_vectors() + np.testing.assert_array_almost_equal(m.vectors, expected) + + def test_read_binary_be_face_count(self): + m = mesh.Mesh.from_ply_file(str(PLY_BINARY_PATH / 'CubeBigEndian.ply')) + assert len(m.data) == 12 + + def test_read_binary_be_vectors(self): + m = mesh.Mesh.from_ply_file(str(PLY_BINARY_PATH / 'CubeBigEndian.ply')) + expected = _expected_vectors() + np.testing.assert_array_almost_equal(m.vectors, expected) + + +class TestTriangulation: + def test_quad_faces_produce_correct_triangle_count(self): + m = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Quad.ply')) + # 6 quads -> 6 * 2 = 12 triangles + assert len(m.data) == 12 + + def test_quad_faces_cover_all_vertices(self): + m = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Quad.ply')) + # All 8 cube vertices should appear + unique_verts = np.unique(m.vectors.reshape(-1, 3), axis=0) + assert len(unique_verts) == 8 + + +class TestWritePly: + def test_write_binary_round_trip(self, tmp_path): + original = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + out_path = tmp_path / 'cube_out.ply' + original.save_ply(str(out_path)) + + reloaded = mesh.Mesh.from_ply_file(str(out_path)) + np.testing.assert_array_almost_equal( + original.vectors, reloaded.vectors + ) + + def test_write_ascii_round_trip(self, tmp_path): + original = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + out_path = tmp_path / 'cube_out.ply' + original.save_ply(str(out_path), mode='ascii') + + reloaded = mesh.Mesh.from_ply_file(str(out_path)) + np.testing.assert_array_almost_equal( + original.vectors, reloaded.vectors + ) + + def test_write_big_endian_round_trip(self, tmp_path): + original = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + out_path = tmp_path / 'cube_be.ply' + original.save_ply(str(out_path), mode='binary_big_endian') + + reloaded = mesh.Mesh.from_ply_file(str(out_path)) + np.testing.assert_array_almost_equal( + original.vectors, reloaded.vectors + ) + + def test_write_with_filehandle(self, tmp_path): + original = mesh.Mesh.from_ply_file(str(PLY_ASCII_PATH / 'Cube.ply')) + out_path = tmp_path / 'cube_fh.ply' + with open(out_path, 'wb') as fh: + original.save_ply(str(out_path), fh=fh) + + reloaded = mesh.Mesh.from_ply_file(str(out_path)) + assert len(reloaded.data) == 12 + + def test_stl_to_ply_round_trip(self, tmp_path): + """Load an STL, save as PLY, reload, compare.""" + stl_path = ( + pathlib.Path(__file__).parent / 'stl_binary' / 'HalfDonut.stl' + ) + original = mesh.Mesh.from_file(str(stl_path)) + ply_path = tmp_path / 'donut.ply' + original.save_ply(str(ply_path)) + + reloaded = mesh.Mesh.from_ply_file(str(ply_path)) + np.testing.assert_array_almost_equal( + original.vectors, reloaded.vectors + ) + + +class TestPlyErrors: + def test_invalid_magic(self, tmp_path): + bad = tmp_path / 'bad.ply' + bad.write_bytes(b'not a ply file\n') + with pytest.raises(ValueError, match='Not a PLY'): + mesh.Mesh.from_ply_file(str(bad)) + + def test_missing_vertex_element(self, tmp_path): + bad = tmp_path / 'bad.ply' + bad.write_bytes( + b'ply\n' + b'format ascii 1.0\n' + b'element face 1\n' + b'property list uchar int vertex_indices\n' + b'end_header\n' + b'3 0 1 2\n' + ) + with pytest.raises(ValueError, match='no vertex element'): + mesh.Mesh.from_ply_file(str(bad)) + + def test_missing_face_element(self, tmp_path): + bad = tmp_path / 'bad.ply' + bad.write_bytes( + b'ply\n' + b'format ascii 1.0\n' + b'element vertex 3\n' + b'property float x\n' + b'property float y\n' + b'property float z\n' + b'end_header\n' + b'0 0 0\n' + b'1 0 0\n' + b'0 1 0\n' + ) + with pytest.raises(ValueError, match='no face element'): + mesh.Mesh.from_ply_file(str(bad)) + + def test_truncated_ascii_file(self, tmp_path): + bad = tmp_path / 'bad.ply' + bad.write_bytes( + b'ply\n' + b'format ascii 1.0\n' + b'element vertex 3\n' + b'property float x\n' + b'property float y\n' + b'property float z\n' + b'element face 1\n' + b'property list uchar int vertex_indices\n' + b'end_header\n' + b'0 0 0\n' + # Missing 2 vertices and face data + ) + with pytest.raises(ValueError): + mesh.Mesh.from_ply_file(str(bad)) + + +class TestPlyHelpers: + def test_load_ascii_uses_speedup_reader_when_available( + self, monkeypatch, tmp_path + ): + expected = np.zeros(0, dtype=mesh.Mesh.dtype) + captured: dict[str, object] = {} + + def fake_ascii_read(fh, header): + captured['header'] = header + captured['fileno'] = fh.fileno() + return b'fast-path', expected + + monkeypatch.setattr(stl_module, '_ascii_read', fake_ascii_read) + path = tmp_path / 'speedup-input.stl' + path.write_bytes(b'solid speedup\nendsolid speedup\n') + + with open(path, 'rb') as fh: + name, data = mesh.Mesh._load_ascii(fh, b'solid speedup') + + assert name == b'fast-path' + assert data is expected + assert captured['header'] == b'solid speedup' + + def test_write_ascii_uses_speedup_writer_when_available( + self, monkeypatch, tmp_path + ): + captured: dict[str, object] = {} + + def fake_ascii_write(fh, name, data): + captured['name'] = name + captured['rows'] = len(data) + fh.write(b'fast-ascii') + + monkeypatch.setattr(stl_module, '_ascii_write', fake_ascii_write) + triangle = mesh.Mesh(_triangle_mesh_data(), remove_empty_areas=False) + path = tmp_path / 'speedup-output.stl' + + with open(path, 'wb') as fh: + triangle._write_ascii(fh, 'triangle') + + assert path.read_bytes() == b'fast-ascii' + assert captured == {'name': b'triangle', 'rows': 1} + + def test_parse_header_ignores_blank_lines(self): + fh = io.BytesIO( + b'ply\nformat ascii 1.0\n\nobj_info sample-object\nend_header\n' + ) + + format_str, elements, obj_name = ply._parse_header(fh) + + assert format_str == 'ascii' + assert elements == [] + assert obj_name == 'sample-object' + + def test_parse_header_requires_end_header(self): + fh = io.BytesIO(b'ply\nformat ascii 1.0\n') + + with pytest.raises( + ValueError, match='Unexpected end of file while parsing header' + ): + ply._parse_header(fh) + + def test_parse_header_requires_format_line(self): + fh = io.BytesIO(b'ply\nelement vertex 0\nend_header\n') + + with pytest.raises(ValueError, match='No format line found'): + ply._parse_header(fh) + + def test_find_elements_handles_extra_elements(self): + vertex = _make_vertex_element('x', 'y', 'z') + face = _make_face_element() + extra = ply._Element('commentary', 1) + + found_vertex, found_face = ply._find_elements([vertex, face, extra]) + + assert found_vertex is vertex + assert found_face is face + + def test_find_xyz_indices_accepts_trailing_properties(self): + vertex = _make_vertex_element('x', 'y', 'z', 'temperature') + + assert ply._find_xyz_indices(vertex) == (0, 1, 2) + + def test_find_xyz_indices_requires_all_axes(self): + vertex = _make_vertex_element('x', 'y') + + with pytest.raises(ValueError, match='missing x, y, or z'): + ply._find_xyz_indices(vertex) + + def test_read_ascii_skips_unknown_elements(self): + vertex = _make_vertex_element('x', 'y', 'z', count=3) + edge = ply._Element('edge', 1) + edge.properties.append(ply._Property('id', 'uchar')) + face = _make_face_element() + + vertices, faces = ply._read_ascii( + io.BytesIO(b'0 0 0\n1 0 0\n0 1 0\n7\n3 0 1 2\n'), + [vertex, edge, face], + ) + + np.testing.assert_array_equal( + vertices, + np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + dtype=np.float32, + ), + ) + assert faces == [[0, 1, 2]] + + def test_read_binary_faces_requires_list_property(self): + face = _make_face_element(include_list=False) + + with pytest.raises(ValueError, match='no list property'): + ply._read_binary_faces(io.BytesIO(), face, '<') + + def test_read_binary_faces_requires_complete_face_count(self): + face = _make_face_element() + + with pytest.raises(ValueError, match='Unexpected EOF reading face'): + ply._read_binary_faces(io.BytesIO(), face, '<') + + def test_read_binary_faces_requires_complete_face_indices(self): + face = _make_face_element() + fh = io.BytesIO(struct.pack('=1.0 + tox-gh-actions>=3.0 +env_list = + py{310,311,312,313}-numpy{1,2} + speedups + lint + pyrefly + docs + docs-examples + coverage +skip_missing_interpreters = true [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 3.13: py313 - pypy3: pypy3 - -[testenv:flake8] -basepython=python -commands = flake8 --ignore=W391 stl tests +# ── Test matrix ────────────────────────────────────────────── + +[testenv] +description = Run tests under {basepython} with {envname} +deps = + .[tests] + numpy1: numpy>=1.24,<2 + numpy2: numpy>=2,<3 +commands = + pytest --basetemp={envtmpdir}/tmp --cov-report= {posargs} +set_env = + COVERAGE_FILE = {toxworkdir}/.coverage.{envname} + +# py313 + numpy1 is not supported +[testenv:py313-numpy1] +deps = +commands = python -c "print('SKIP: numpy 1.x does not support Python 3.13')" + +# ── Speedups ──────────────────────────────────────────────── + +[testenv:speedups] +description = Run tests with the speedups C extension +basepython = python3.13 +deps = + .[tests] + .[fast] + numpy>=2,<3 +commands = + pytest --basetemp={envtmpdir}/tmp --cov-report= {posargs} +set_env = + COVERAGE_FILE = {toxworkdir}/.coverage.speedups + +# ── Lint ───────────────────────────────────────────────────── + +[testenv:lint] +description = Lint with ruff +skip_install = true +deps = ruff>=0.15.0 +commands = + ruff check stl tests + ruff format --check stl tests + +# ── Type checking ──────────────────────────────────────────── + +[testenv:pyrefly] +description = Type-check with pyrefly +deps = + . + pyrefly>=0.1 +commands = + pyrefly check + +[testenv:ty] +description = Type-check with ty (run explicitly: tox -e ty) +deps = + . + ty +commands = + ty check stl + +# ── Docs ───────────────────────────────────────────────────── [testenv:docs] -basepython=python -changedir=docs -commands= +description = Build Sphinx documentation +deps = .[docs] +changedir = docs +commands = sphinx-build -b html -d {envtmpdir}/doctrees . {envtmpdir}/html -# sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html -# [testenv:py38] -# # one optional test has PyQt5 dep, only test that once -# deps = -# -rtests/requirements.txt -# PyQt5 +[testenv:docs-examples] +description = Run README and docs code samples in sandboxes +deps = + .[tests,docs] + matplotlib>=3.8 +set_env = + NUMPY_STL_RUN_DOCS_EXAMPLES = 1 +commands = + pytest --basetemp={envtmpdir}/tmp tests/docs_examples + +# ── Coverage ───────────────────────────────────────────────── + +[testenv:coverage] +description = Combine coverage from test envs and enforce threshold +skip_install = true +deps = coverage>=7.0 +depends = + py{310,311,312,313}-numpy{1,2} + speedups +set_env = + COVERAGE_FILE = {toxworkdir}/.coverage +commands = + coverage combine {toxworkdir} + coverage report --fail-under=100