From 455a3a85fda8e3df440d1e05cadaf2191e45e9ed Mon Sep 17 00:00:00 2001 From: LayerDynamics Date: Wed, 10 Jun 2026 09:57:34 -0500 Subject: [PATCH 1/2] Fix test suite for OCC 7.9 / Python 3.14, add CI, docs, examples, lint toolchain Bring the package to a green, lint-clean state on the current conda-forge stack (pythonocc-core 7.9.3, NumPy 2, Python 3.14) and stand up the project tooling that was previously empty. Core/runtime fixes: - Reparent Vertex/Edge/Wire/Shell/Solid/Compound under Shape, pruning the mixins Shape already provides to avoid C3 MRO errors; shape.py imports the concrete wrappers lazily to break the resulting import cycle. - get_triangles(): use the OCC 7.6+ triangulation API (BRep_Tool.Triangulation + NbNodes/Node/NbTriangles/Triangle().Get()) instead of removed Nodes()/IsNull(). - Unify find_closest_point_data() to the (point, middle, distance) 3-tuple contract every caller already expected. - pyocc_shape()/create_from_topods(): down-cast to the concrete TopoDS_* type so the file loaders (load_stl/load_brep) and explorers wrap shapes correctly. - Fix Edge.point() raw-parameter branch (Interval.a/.b, not .start/.end), DisplayMaterial mutable-numpy dataclass defaults (default_factory), an undefined Shell reference in Solid.make_from_shell, dedup in vertices_from_face/edges_from_vertex, and wires_from_face double-wrapping. - Boolean ops (union/difference/intersection/subtraction) are now instance methods; OffscreenRenderer accepts width/height; implement the missing MultiViewRenderer/BatchProcessor methods and the 5 missing viewer methods; replace the _process_single_shape stub data with real analysis/exports. - Add UniformGrid + sample_face_uniformly/sample_edge_uniformly; make uv_bounds 2D + add Box.__iter__; improve arc-length finder precision. Tests: 275 passed, 19 skipped (display-gated; run under Xvfb in CI), 0 failed. Added io_test.py round-trips and an Edge.point raw-parameter regression test. Tooling/docs: - Implement check.yml / build.yml / publish.yml (micromamba env; ruff + black + pymarkdown gates; pytest under Xvfb; sdist/wheel + smoke import; PyPI trusted publishing). - Drive ruff to zero; switch import sorting to ruff (black-compatible, no oscillation); add PyMarkdown + markdownlint config; fix pyproject URLs. - Add LICENSE, CONTRIBUTING.md, docs/ (architecture, developer guide, API), and runnable examples/. Remove the dead repo-root __init__.py shim that shadowed the installed src/pyocc package under pytest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 71 +++ .github/workflows/check.yml | 64 ++ .github/workflows/publish.yml | 62 ++ .gitignore | 1 + .markdownlint-cli2.jsonc | 11 + .markdownlint.jsonc | 9 + CONTRIBUTING.md | 115 ++++ LICENSE | 21 + README.md | 98 +-- __init__.py | 198 ------ docs/api/README.md | 141 +++++ docs/architecture.md | 145 +++++ docs/developer_guide.md | 128 ++++ environment.yml | 1 + examples/01_basic_shapes.py | 54 ++ examples/02_geometry_analysis.py | 64 ++ examples/03_uvgrid_sampling.py | 49 ++ examples/04_file_io.py | 51 ++ examples/05_topology_graph.py | 34 + examples/README.md | 21 + pyproject.toml | 29 +- src/pyocc/__init__.py | 300 ++++++--- src/pyocc/analysis/__init__.py | 20 +- src/pyocc/analysis/section.py | 126 ++-- src/pyocc/ast.py | 579 +++++++++--------- src/pyocc/base.py | 184 +++--- src/pyocc/batch/__init__.py | 20 +- src/pyocc/batch/render_multiview.py | 446 ++++++++++---- src/pyocc/comparison.py | 136 ++-- src/pyocc/compound.py | 74 ++- src/pyocc/constraints.py | 106 ++-- src/pyocc/context.py | 54 +- src/pyocc/display_data.py | 189 +++--- src/pyocc/edge.py | 113 ++-- src/pyocc/edge_data_extractor.py | 70 +-- src/pyocc/entity_mapper.py | 90 ++- src/pyocc/face.py | 200 +++--- src/pyocc/geometry/__init__.py | 44 +- src/pyocc/geometry/arc_length_param_finder.py | 36 +- src/pyocc/geometry/box.py | 48 +- src/pyocc/geometry/geom_utils.py | 174 +++--- src/pyocc/geometry/interval.py | 7 +- src/pyocc/geometry/mesh_utils.py | 117 ++-- src/pyocc/geometry/tri_utils.py | 201 +++--- src/pyocc/graph.py | 73 +-- src/pyocc/io.py | 116 ++-- src/pyocc/jupyter_viewer.py | 241 ++++---- src/pyocc/plane.py | 36 +- src/pyocc/shape.py | 119 ++-- src/pyocc/shell.py | 26 +- src/pyocc/sketch.py | 132 ++-- src/pyocc/solid.py | 284 +++++---- src/pyocc/uvgrid.py | 125 +++- src/pyocc/vertex.py | 20 +- src/pyocc/viewer.py | 156 ++++- src/pyocc/wire.py | 30 +- tests/__init__.py | 2 +- tests/src/__init__.py | 2 +- tests/src/pyocc/__init__.py | 2 +- tests/src/pyocc/analysis/__init__.py | 2 +- .../src/pyocc/arc_length_param_finder_test.py | 11 +- tests/src/pyocc/base_test.py | 26 +- tests/src/pyocc/compound_test.py | 17 +- tests/src/pyocc/display_data_test.py | 144 ++--- tests/src/pyocc/edge_data_extractor_test.py | 122 ++-- tests/src/pyocc/edge_test.py | 33 +- tests/src/pyocc/entity_mapper_test.py | 7 +- tests/src/pyocc/face_test.py | 21 +- tests/src/pyocc/geom_utils_test.py | 42 +- tests/src/pyocc/geometry/__init__.py | 2 +- tests/src/pyocc/geometry/box_test.py | 51 +- tests/src/pyocc/geometry/interval_test.py | 6 +- tests/src/pyocc/graph_test.py | 94 +-- tests/src/pyocc/io_test.py | 61 ++ tests/src/pyocc/render_multiview_test.py | 96 +-- tests/src/pyocc/shape_test.py | 21 +- tests/src/pyocc/shell_test.py | 15 +- tests/src/pyocc/solid_test.py | 23 +- tests/src/pyocc/test_uvgrid_simple.py | 41 +- tests/src/pyocc/tri_utils_test.py | 275 +++------ tests/src/pyocc/uvgrid_test.py | 22 +- tests/src/pyocc/vertex_test.py | 7 +- tests/src/pyocc/viewer_test.py | 39 +- tests/src/pyocc/wire_test.py | 17 +- 84 files changed, 4429 insertions(+), 2831 deletions(-) create mode 100644 .markdownlint-cli2.jsonc create mode 100644 .markdownlint.jsonc create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE delete mode 100644 __init__.py create mode 100644 docs/api/README.md create mode 100644 docs/architecture.md create mode 100644 docs/developer_guide.md create mode 100644 examples/01_basic_shapes.py create mode 100644 examples/02_geometry_analysis.py create mode 100644 examples/03_uvgrid_sampling.py create mode 100644 examples/04_file_io.py create mode 100644 examples/05_topology_graph.py create mode 100644 examples/README.md create mode 100644 tests/src/pyocc/io_test.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e69de29..865836c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -0,0 +1,71 @@ +name: Build + +# Build the source distribution and wheel, validate their metadata, and verify +# the built wheel actually imports inside a real OpenCASCADE (conda) environment. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build distributions + run: | + python -m pip install --upgrade pip build twine + python -m build + + - name: Validate distribution metadata + run: twine check dist/* + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: pyocc-dist + path: dist/* + if-no-files-found: error + + smoke-import: + name: Smoke-test wheel import (conda) + needs: build + runs-on: ubuntu-latest + defaults: + run: + shell: bash -el {0} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: pyocc-dist + path: dist + + - name: Provision conda environment (provides OpenCASCADE) + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: environment.yml + cache-environment: true + + - name: Install built wheel and import pyocc + run: | + pip install dist/*.whl + python -c "import pyocc; print('pyocc', pyocc.__version__, 'imported successfully')" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e69de29..0d341a6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -0,0 +1,64 @@ +name: Checks + +# Lint, type-check, and test on every push to main and every pull request. +# pythonocc-core (the OpenCASCADE bindings) is only distributed via conda-forge, +# so CI provisions the environment with micromamba from environment.yml rather +# than pip. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint, type-check & test + runs-on: ubuntu-latest + defaults: + run: + # Login shell so the micromamba-activated environment is on PATH. + shell: bash -el {0} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Provision conda environment (pythonocc-core is conda-only) + uses: mamba-org/setup-micromamba@v2 + with: + environment-file: environment.yml + cache-environment: true + + - name: Install pyocc (editable) + run: pip install -e . + + # Lint and format are hard gates: ruff (which also enforces import order + # via its black-compatible isort) and black must both be clean. + - name: Ruff (lint + import order) + run: ruff check src tests examples + + - name: Black (format check) + run: black --check src tests examples + + - name: Markdown lint (PyMarkdown) + run: python -m pymarkdown scan README.md CLAUDE.md CONTRIBUTING.md docs examples + + # mypy stays informational for now: the OCC-heavy code carries type debt + # and the project's mypy config is intentionally lenient. + - name: mypy (type check) + run: mypy src/pyocc + continue-on-error: true + + - name: Install Xvfb (virtual display for offscreen-render tests) + run: sudo apt-get update && sudo apt-get install -y xvfb + + # Run under a virtual framebuffer so the OpenCASCADE offscreen renderer can + # create a GL context — this lets the viewer/render_multiview tests actually + # execute instead of skipping for lack of a display. + - name: Pytest (gate) + run: xvfb-run -a pytest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e69de29..2985725 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -0,0 +1,62 @@ +name: Publish + +# Build and publish distributions to PyPI when a GitHub Release is published. +# +# Publishing uses PyPI Trusted Publishing (OIDC) — no API token secret is +# required, but a one-time setup is needed on PyPI: +# 1. Create the project / claim the name "pyocc" on https://pypi.org +# 2. Add a Trusted Publisher pointing at this repo + this workflow +# (Owner: LatticeLabsAI, Repo: pyocc, Workflow: publish.yml, +# Environment: pypi). +# See https://docs.pypi.org/trusted-publishers/ for details. + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + build: + name: Build sdist + wheel + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Build and validate distributions + run: | + python -m pip install --upgrade pip build twine + python -m build + twine check dist/* + + - name: Upload distributions + uses: actions/upload-artifact@v4 + with: + name: pyocc-dist + path: dist/* + if-no-files-found: error + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/pyocc + permissions: + # IMPORTANT: required for Trusted Publishing (OIDC). + id-token: write + steps: + - name: Download built distributions + uses: actions/download-artifact@v4 + with: + name: pyocc-dist + path: dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index b99e136..dfc50a3 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,4 @@ dmypy.json # Logs logs/ *.log +CLAUDE.md \ No newline at end of file diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..de14ce4 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,11 @@ +{ + // markdownlint-cli2 settings (globs + ignores). Rule config lives in + // .markdownlint.jsonc so the VS Code extension shares the same rules. + "globs": ["**/*.md"], + "ignores": [ + "node_modules", + ".git", + "**/.pytest_cache/**", + "**/*.egg-info/**" + ] +} diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc new file mode 100644 index 0000000..94fdb97 --- /dev/null +++ b/.markdownlint.jsonc @@ -0,0 +1,9 @@ +{ + // Shared markdownlint rule configuration, read by both the VS Code + // markdownlint extension and markdownlint-cli2 (local + CI). + // + // MD013 (line length) is not meaningful for rendered prose, so it is + // disabled. Every other rule — including MD060 (table column style) — stays + // enabled at its default. + "MD013": false +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e47d632 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing to PyOCC + +Thanks for your interest in improving PyOCC! This guide covers how to set up a +development environment, run the test suite, and the architectural conventions +the codebase relies on. + +## Development environment + +PyOCC is built on **pythonocc-core**, the OpenCASCADE Python bindings, which are +distributed **only through conda-forge** (they are *not* pip-installable). You +must therefore develop inside a conda/mamba environment. + +```bash +# Create the environment (mamba is faster; conda works too) +mamba env create -f environment.yml +mamba activate pyocc + +# Install PyOCC itself in editable mode +pip install -e . +``` + +> The environment name (`pyocc`) comes from `environment.yml`. The common +> mistake `mamba create env pyocc` passes `env` and `pyocc` as *package* names — +> always use the `env create -f environment.yml` form. + +`environment.yml` pins only `python>=3.9`, so a fresh solve currently resolves to +a recent Python (3.14) and OpenCASCADE 7.9.x. The package targets Python +**>=3.10** (see `pyproject.toml`). + +## Running the tests + +```bash +pytest # full suite +pytest tests/src/pyocc/solid_test.py # one file +pytest tests/src/pyocc/solid_test.py::SolidTest::test_make_box # one test +pytest --cov=pyocc tests/ # with coverage +``` + +Tests live under `tests/src/pyocc/` mirroring the `src/pyocc/` layout, are named +`*_test.py`, and are mostly `unittest.TestCase` subclasses (a few are plain +`pytest` classes using `setup_method`). They build OCC primitives directly and +assert on real geometry/topology — keep new tests in that style. + +A change is not complete until `pytest` passes with no new failures. + +## Code style + +```bash +ruff check src tests # lint + import order (add --fix to sort imports) +black src tests # format (line length 100) +pymarkdown scan README.md docs examples # markdown lint (rules in pyproject) +mypy src/pyocc # type check (lenient; OCC.* is ignored) +``` + +- Follow PEP 8; line length is **100** (configured in `pyproject.toml`). +- Use type hints on public APIs and Google-style docstrings. +- Match the surrounding code's idioms and comment density. + +The CI `Checks` workflow gates on `ruff` (lint + import order), `black --check`, +and `pymarkdown` (markdown lint), then runs `pytest` under Xvfb (a virtual display, +so the offscreen-render tests execute). `mypy` runs informationally for now, since +the OCC-heavy code still carries type debt. + +## Architecture conventions + +Read `docs/architecture.md` for the full picture. The conventions most likely to +trip you up: + +### Every shape wraps a `TopoDS_Shape` + +Each wrapper stores the underlying OpenCASCADE shape in `self._shape` and exposes +it via `.topods_shape()`. To add geometry behavior: get the raw object with +`.topods_shape()`, operate with `OCC.Core.*`, and wrap the result back into the +appropriate PyOCC class. Geometric data crosses the boundary as NumPy arrays. + +### Shape hierarchy and mixins + +Every concrete shape — `Vertex`, `Edge`, `Wire`, `Face`, `Shell`, `Solid`, +`Compound` — subclasses `Shape` and additionally mixes in only the container +mixins that `Shape` does **not** already provide. `Shape` supplies +`FaceContainerMixin`, `EdgeContainerMixin`, and `BoundingBoxMixin`, so a subclass +must **not** re-list those — doing so re-triggers a C3 MRO `TypeError`. List the +*other* mixins the type needs, e.g. `class Edge(Shape, VertexContainerMixin)`. + +Operations shared across all shapes (`translate`, `scale`, `rotate_axis_angle`, +`transform`, `valid`, `reversed`, `find_closest_point_data`, +`save_to_occ_native`) live on `Shape` — add cross-shape behavior there, not per +class. To add a capability to a subset of shapes, add or reuse a mixin in +`base.py`. + +### Circular-import discipline + +The shape modules import each other. `base.py` guards cross-shape imports behind +`TYPE_CHECKING`, and `shape.py` imports the concrete wrappers **lazily inside +`pyocc_shape()`** so that those modules can import `Shape` at module top level. +When adding imports between shape modules, follow this pattern — a naive +top-level `from .face import Face` can reintroduce an import cycle. + +### `find_closest_point_data` contract + +All shapes return the same 3-tuple `(point, middle, distance)` (indexable +`[0]/[1]/[2]`), where `middle` is the parameter (Edge/Wire), the closest +sub-`Face` (Solid/Face/Shell), or `None` (Vertex). Never return a dict. + +## Pull requests + +1. Branch off `main`. +2. Keep changes focused; match existing style. +3. Add or update tests for any behavior change, and a regression test for any + bug fix. +4. Ensure `pytest` is green and run the formatters before opening the PR. +5. Describe what changed and why. + +By contributing you agree that your contributions are licensed under the +project's [MIT License](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..deb6cef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ryan O'Boyle / LatticeLabsAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b27ccb4..d65f77f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # PyOCC: Modern Python CAD Library -[![Python](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) -[![OpenCASCADE](https://img.shields.io/badge/OpenCASCADE-7.8.1-green.svg)](https://www.opencascade.com/) +[![Checks](https://github.com/LatticeLabsAI/pyocc/actions/workflows/check.yml/badge.svg)](https://github.com/LatticeLabsAI/pyocc/actions/workflows/check.yml) +[![Python](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![OpenCASCADE](https://img.shields.io/badge/OpenCASCADE-7.9-green.svg)](https://www.opencascade.com/) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **PyOCC** is a modern, Pythonic computer-aided design (CAD) library built on top of [pythonocc-core](https://github.com/tpaviot/pythonocc-core). It provides a clean, intuitive API for geometric modeling, visualization, and analysis while leveraging the power of the OpenCASCADE geometric modeling kernel. @@ -9,24 +10,28 @@ ## 🚀 Key Features ### **Clean Architecture** + - **Mixin-Based Design**: Compose functionality from reusable components - **Lazy Evaluation**: Compute expensive operations only when needed - **Type Safety**: Comprehensive type hints throughout the codebase - **NumPy Integration**: Geometric data as NumPy arrays for scientific computing ### **Comprehensive Geometry Support** + - **3D Solids**: Volumes with Boolean operations (union, difference, intersection) - **Surfaces & Faces**: UV-parameterized surfaces with curvature analysis - **Curves & Edges**: Parametric curves with arc-length parameterization - **Topological Analysis**: Rich connectivity queries and adjacency relationships ### **Multiple Visualization Backends** + - **Interactive 3D Viewer**: Cross-platform desktop visualization - **Jupyter Integration**: WebGL-based notebook visualization - **Offscreen Rendering**: Batch image generation and multi-view rendering - **Custom Materials**: Advanced shading and lighting controls ### **Industrial I/O Support** + - **STEP/IGES**: Industry-standard CAD file formats - **STL**: 3D printing and rapid prototyping - **Native OCC**: High-performance binary format @@ -37,32 +42,33 @@ ## 📦 Installation ### Prerequisites -- Python 3.8 or higher -- NumPy -- pythonocc-core 7.8.1+ -### Install from Source -```bash -git clone https://github.com/upsln/pyocc.git -cd pyocc -pip install -e . -``` +- conda or mamba (pythonocc-core is distributed via conda-forge only) +- Python 3.10 or higher +- pythonocc-core 7.9+ (OpenCASCADE), NumPy, SciPy -### Dependencies -```bash -# Core dependencies -pip install numpy pythonocc-core +### Install from source -# Visualization dependencies (optional) -pip install PyQt6 # or PyQt5, tkinter comes with Python +`pythonocc-core` (the OpenCASCADE bindings) is **not** available on PyPI — it is +distributed through conda-forge. Use the provided conda/mamba environment, which +also installs the visualization, Jupyter, and graph dependencies: -# Jupyter support (optional) -pip install jupyter ipywidgets pythreejs +```bash +git clone https://github.com/LatticeLabsAI/pyocc.git +cd pyocc + +# Create and activate the environment (mamba is faster; conda also works) +mamba env create -f environment.yml +mamba activate pyocc -# Graph analysis (optional) -pip install networkx +# Install PyOCC itself (editable) +pip install -e . ``` +> `pip install -e .` installs the pure-Python dependencies and PyOCC, but **not** +> OpenCASCADE — that comes from the conda environment above. A common mistake is +> `mamba create env pyocc`; the correct form is `mamba env create -f environment.yml`. + --- ## 🏗️ Architecture @@ -71,18 +77,20 @@ PyOCC uses a **mixin-based architecture** that composes functionality from speci ```python class Solid(Shape, - VertexContainerMixin, EdgeContainerMixin, - FaceContainerMixin, WireContainerMixin, ShellContainerMixin, - BoundingBoxMixin, SurfacePropertiesMixin, VolumePropertiesMixin, - TriangulatorMixin): - """A 3D solid composed from multiple capability mixins.""" + VertexContainerMixin, WireContainerMixin, ShellContainerMixin, + SurfacePropertiesMixin, VolumePropertiesMixin, TriangulatorMixin, + BottomUpFaceIterator, BottomUpEdgeIterator): + """A 3D solid composed from Shape plus capability mixins.""" pass + +# `Shape` already provides FaceContainerMixin, EdgeContainerMixin, and +# BoundingBoxMixin, so subclasses must NOT re-list them (doing so breaks the MRO). ``` ### Core Mixins | Mixin | Functionality | -|-------|---------------| +| ----- | ------------- | | `VertexContainerMixin` | Access and iterate over vertices | | `EdgeContainerMixin` | Edge operations and adjacency queries | | `FaceContainerMixin` | Face access with topological relationships | @@ -108,9 +116,9 @@ box = Solid.make_box(10, 20, 30) # Create a sphere sphere = Solid.make_sphere(radius=15, center=np.array([25, 0, 0])) -# Boolean operations -union_result = box.union(sphere) -difference_result = box.difference(sphere) +# Boolean operations (static methods taking two solids) +union_result = Solid.union(box, sphere) +difference_result = Solid.difference(box, sphere) # Display the result viewer = create_viewer() @@ -287,11 +295,11 @@ refined_vertices, refined_triangles = box.get_triangles() ### Topological Graph Analysis ```python -from pyocc.graph import create_face_adjacency_graph, create_vertex_adjacency_graph +from pyocc.graph import face_adjacency, vertex_adjacency -# Create graphs for analysis -face_graph = create_face_adjacency_graph(box) -vertex_graph = create_vertex_adjacency_graph(box) +# Create NetworkX adjacency graphs for analysis +face_graph = face_adjacency(box) +vertex_graph = vertex_adjacency(box) # NetworkX integration import networkx as nx @@ -376,7 +384,7 @@ assert analyzer # All edges properly oriented ## 🏗️ Project Structure -``` +```text pyocc/ ├── src/pyocc/ │ ├── base.py # Core mixin classes @@ -410,13 +418,16 @@ We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) f ### Development Setup ```bash -git clone https://github.com/upsln/pyocc.git +git clone https://github.com/LatticeLabsAI/pyocc.git cd pyocc -pip install -e ".[dev]" -pytest tests/ +mamba env create -f environment.yml +mamba activate pyocc +pip install -e . +pytest ``` ### Code Style + - Follow PEP 8 guidelines - Use type hints for all public APIs - Comprehensive docstrings (Google style) @@ -450,18 +461,19 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## 👨‍💻 Author -**Ryan O'Boyle** -- GitHub: [@LayerDynamics](https://github.com/LayerDynamics) -- Email: rvo@upsln.com +- **Name**: Ryan O'Boyle +- **GitHub**: [@LayerDynamics](https://github.com/LayerDynamics) +- **Email**: [layerdynamics@proton.me](layerdynamics@proton.me) --- ## 🔗 Links -- [PyOCC Documentation](https://upsln.github.io/pyocc/) +- [Architecture Guide](docs/architecture.md) +- [Developer Guide](docs/developer_guide.md) - [OpenCASCADE Technology](https://www.opencascade.com/) - [pythonocc-core](https://github.com/tpaviot/pythonocc-core) -- [Issue Tracker](https://github.com/upsln/pyocc/issues) +- [Issue Tracker](https://github.com/LatticeLabsAI/pyocc/issues) --- diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 21ccbc3..0000000 --- a/__init__.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -Python OpenCASCADE (pyocc) module. - -This module provides Python bindings for the OpenCASCADE geometry kernel. -""" - -import os -import sys -import warnings -from pathlib import Path - -# Add the src directory to the Python path -current_dir = Path(__file__).parent.absolute() -src_dir = current_dir / "src" -if src_dir.exists() and str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - -# Import core classes -try: - # Import from the src/pyocc directory - from .src.pyocc.shape import Shape - from .src.pyocc.vertex import Vertex - from .src.pyocc.edge import Edge - from .src.pyocc.wire import Wire - from .src.pyocc.face import Face - from .src.pyocc.shell import Shell - from .src.pyocc.solid import Solid - from .src.pyocc.compound import Compound - from .src.pyocc.plane import Plane - from .src.pyocc.sketch import Sketch - from .src.pyocc.constraints import Constraint, ConstraintType - - # Analysis and utilities - from .src.pyocc.edge_data_extractor import EdgeDataExtractor - from .src.pyocc.entity_mapper import EntityMapper - from .src.pyocc.comparison import ComparisonLevel, ShapeFingerprint, compute_shape_hash, shapes_equal, shape_similarity - from .src.pyocc.display_data import DisplayShape, DisplayMaterial, DisplayVertex, DisplayEdge, DisplayFace - from .src.pyocc.base import ( - TopologyUtils, VertexContainerMixin, EdgeContainerMixin, FaceContainerMixin, - WireContainerMixin, ShellContainerMixin, SolidContainerMixin, - BoundingBoxMixin, TriangulatorMixin - ) - - # I/O operations - from .src.pyocc.io import save_step, load_step, save_stl, load_stl, save_brep, load_brep, save_iges, load_iges - - # Visualization - from .src.pyocc.viewer import Viewer, OffscreenRenderer, create_viewer, display_shape - from .src.pyocc.jupyter_viewer import JupyterViewer, JupyterViewerWidget - - # Grid and mesh utilities - from .src.pyocc.uvgrid import uvgrid, ugrid, uvgrid_points, uvgrid_normals - - # Graph analysis - from .src.pyocc.graph import TopologyGraph, FaceAdjacencyGraph, VertexAdjacencyGraph - - # Geometry utilities - from .src.pyocc.geometry import Interval, Box, ArcLengthParamFinder - - # Analysis modules - from .src.pyocc.analysis.section import SectionResult, SectionType, create_planar_section, analyze_section_properties - - # Batch processing - from .src.pyocc.batch.render_multiview import MultiViewRenderer, RenderSettings, ViewAngle, BatchProcessor - - # Re-export the imported classes - __all__ = [ - 'Shape', - 'Edge', - 'Face', - 'Wire', - 'Solid', - 'Vertex', - 'Compound', - 'Shell', - 'Plane', - 'Sketch', - 'Constraint', - 'ConstraintType', - 'EdgeDataExtractor', - 'EntityMapper', - 'ComparisonLevel', - 'ShapeFingerprint', - 'compute_shape_hash', - 'shapes_equal', - 'shape_similarity', - 'DisplayShape', - 'DisplayMaterial', - 'DisplayVertex', - 'DisplayEdge', - 'DisplayFace', - 'TopologyUtils', - 'VertexContainerMixin', - 'EdgeContainerMixin', - 'FaceContainerMixin', - 'WireContainerMixin', - 'ShellContainerMixin', - 'SolidContainerMixin', - 'BoundingBoxMixin', - 'TriangulatorMixin', - 'save_step', - 'load_step', - 'save_stl', - 'load_stl', - 'save_brep', - 'load_brep', - 'save_iges', - 'load_iges', - 'Viewer', - 'OffscreenRenderer', - 'create_viewer', - 'display_shape', - 'JupyterViewer', - 'JupyterViewerWidget', - 'uvgrid', - 'ugrid', - 'uvgrid_points', - 'uvgrid_normals', - 'TopologyGraph', - 'FaceAdjacencyGraph', - 'VertexAdjacencyGraph', - 'Interval', - 'Box', - 'ArcLengthParamFinder', - 'SectionResult', - 'SectionType', - 'create_planar_section', - 'analyze_section_properties', - 'MultiViewRenderer', - 'RenderSettings', - 'ViewAngle', - 'BatchProcessor', - ] -except Exception as e: - warnings.warn(f"Error importing pyocc modules: {e}") - - # Provide placeholder classes if imports fail - class Shape: - def __init__(self, *args, **kwargs): - pass - - class Edge: - def __init__(self, *args, **kwargs): - pass - - class Face: - def __init__(self, *args, **kwargs): - pass - - class Wire: - def __init__(self, *args, **kwargs): - pass - - class Solid: - def __init__(self, *args, **kwargs): - pass - - class Vertex: - def __init__(self, *args, **kwargs): - pass - - class Compound: - def __init__(self, *args, **kwargs): - pass - - class Shell: - def __init__(self, *args, **kwargs): - pass - - class Plane: - def __init__(self, *args, **kwargs): - pass - - class Sketch: - def __init__(self, *args, **kwargs): - pass - - class Constraint: - def __init__(self, *args, **kwargs): - pass - - class ConstraintType: - pass - - __all__ = [ - 'Shape', - 'Edge', - 'Face', - 'Wire', - 'Solid', - 'Vertex', - 'Compound', - 'Shell', - 'Plane', - 'Sketch', - 'Constraint', - 'ConstraintType', - ] \ No newline at end of file diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..8061cac --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,141 @@ +# PyOCC API Reference + +The public API is what `import pyocc` exports (see `src/pyocc/__init__.py`). This +reference groups it by area. Signatures shown are the common entry points; +consult the docstrings in the source for full parameter lists. + +## Core shapes + +`Shape`, `Vertex`, `Edge`, `Wire`, `Face`, `Shell`, `Solid`, `Compound`. + +All concrete shapes subclass `Shape` and share these operations: + +| Method | Description | +| ------ | ----------- | +| `topods_shape()` | The underlying OpenCASCADE `TopoDS_Shape` | +| `translate(offset)` | New shape translated by a 3-vector | +| `scale(factor)` | New shape scaled (uniform float, or per-axis sequence) | +| `rotate_axis_angle(axis, angle, origin=(0,0,0))` | New shape rotated about an axis | +| `transform(matrix)` | New shape under a 3x4 row-major transform | +| `valid()` | `True` if the geometry passes OCC's analyzer | +| `reversed()` | `True` if the shape orientation is reversed | +| `find_closest_point_data(datum)` | `(point, middle, distance)` — see below | +| `save_to_occ_native(filename)` | Write a binary `.brep` | +| `box()` | Axis-aligned bounding `Box` | +| `num_vertices()` / `num_edges()` / `num_faces()` | De-duplicated sub-shape counts | +| `vertices()` / `edges()` / `faces()` / `wires()` | De-duplicated iterators of sub-shapes | + +`find_closest_point_data(datum)` returns a 3-tuple `(point, middle, distance)` +where `middle` is the curve parameter (Edge/Wire), the closest sub-`Face` +(Solid/Face/Shell), or `None` (Vertex). + +### Solid + +Factory methods (all return a `Solid`): + +```python +Solid.make_box(dx, dy, dz, corner_point=None) +Solid.make_sphere(radius, center=None, ...) +Solid.make_cylinder(radius, height, center=None, axis=None, angle=None) +Solid.make_cone(radius1, radius2, height, ...) +Solid.make_torus(major_radius, minor_radius, ...) +``` + +Boolean operations are **static** and take two solids: + +```python +Solid.union(solid1, solid2) +Solid.difference(solid1, solid2) +Solid.intersection(solid1, solid2) +``` + +Mass properties: `volume()`, `area()`, `center_of_mass()`. + +### Edge + +```python +Edge.make_line_from_points(start_point, end_point) +Edge.make_circle(center, radius, direction=(0, 0, 1)) +Edge.make_arc_of_circle(pt1, pt2, pt3) +``` + +Evaluation: `point(u)`, `tangent(u)`, `length()`, `curve_type()`, `u_bounds()` +(returns an `Interval`). A `u` in `[0, 1]` is treated as a normalized parameter; +values outside that range are treated as raw curve parameters (clamped to bounds). + +### Face + +`point(uv)`, `normal(uv)` (accept a 2-element `[u, v]` array or separate `u, v`), +`uv_bounds()` (a 2D `Box`, unpackable as `u_min, u_max, v_min, v_max`), `area()`. + +## Parametric design + +`Plane`, `Sketch`, `Constraint`, `ConstraintType` — planes, 2D sketches, and the +constraint types used to solve them. + +## Grid & mesh sampling (`uvgrid`) + +```python +uvgrid(face, num_u=10, num_v=10, uvs=False, method="point") # 2D grid of points/normals +ugrid(edge, num_u=10, us=False, method="point") # 1D grid along a curve +uvgrid_points(face, ...) / uvgrid_normals(face, ...) +UniformGrid(face, u_count=10, v_count=10) # .get_points(), .get_parameters(), .get_normals() +sample_face_uniformly(face, num_u=10, num_v=10) # dict: points, normals, parameters +sample_edge_uniformly(edge, num_points=10) # dict: points, tangents, parameters +``` + +## File I/O (`io`) + +```python +save_step(shapes, filename, schema="AP203") load_step(filename) -> Compound +save_iges(shapes, filename) load_iges(filename) -> Compound +save_stl(shape, filename, linear_deflection=0.1, ascii_mode=False) load_stl(filename) -> Shape +save_brep(shapes, filename) load_brep(filename) -> Shape +``` + +Save functions return `True`/`False`; load functions return the wrapped shape or +`None` on failure. + +## Visualization + +- `Viewer`, `create_viewer()`, `display_shape()` — interactive desktop viewer. +- `OffscreenRenderer` — headless image rendering. +- `JupyterViewer` — WebGL widget for notebooks. +- `DisplayShape`, `DisplayMaterial`, `DisplayVertex`, `DisplayEdge`, + `DisplayFace` — display-state dataclasses. +- `batch.render_multiview`: `MultiViewRenderer`, `RenderSettings`, `ViewAngle`, + `BatchProcessor`, `create_documentation_images`, `create_comparison_grid`, + `render_animation_frames`. + +## Topology analysis + +- Graphs (`graph`): `TopologyGraph`, `FaceAdjacencyGraph`, `VertexAdjacencyGraph`, + plus the functions `face_adjacency(shape)` and `vertex_adjacency(shape)` that + return NetworkX graphs directly. +- `EntityMapper` — stable indices for sub-shapes. +- `EdgeDataExtractor` — sampled geometric data along an edge. +- `comparison`: `compute_shape_hash`, `shapes_equal`, `shape_similarity`, + `ShapeFingerprint`, `ComparisonLevel`. +- `analysis.section`: `create_planar_section`, `analyze_section_properties`, + `create_multiple_sections`, `find_section_at_point`, `section_along_curve`, + `SectionResult`, `SectionType`. + +## Geometry utilities + +- `Interval` — 1D interval with `.a` / `.b`, `middle()`, `length()`, + `interpolate(t)`, `normalize(value)`. +- `Box` — axis-aligned box with `.min` / `.max`, `center()`, `size()`; iterable as + interleaved per-axis bounds. +- `ArcLengthParamFinder` — arc-length reparameterization of curves. + +## ParaQL (`ast`) + +A self-contained SQL-like query language: `parse_query(text)` -> `Query`, with the +full AST (`SelectClause`, `WhereClause`, `JoinClause`, aggregations, …) and helper +builders (`literal`, `column`, `function`, `binary_op`, …). Exported but not yet +wired into shape querying. + +## Context + +`PyOCCContext` holds tolerances, deflections, and the coordinate-system setting; +`PyOCCContextManager` scopes a context for a block of operations. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a430a3a --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,145 @@ +# PyOCC Architecture + +PyOCC is a Pythonic wrapper around **pythonocc-core**, the Python bindings for the +OpenCASCADE geometry kernel. Its job is to present the kernel's `TopoDS_Shape` +topology through a clean, object-oriented, NumPy-friendly API. This document +describes the big-picture design — the pieces that span multiple files. + +## The wrapping contract + +Every PyOCC shape wraps a single OpenCASCADE `TopoDS_Shape`: + +- The raw kernel object is stored on `self._shape`. +- `.topods_shape()` returns it; typed accessors (`.topods_solid()`, + `.topods_face()`, …) return the down-cast form. +- Geometric data crosses the boundary as NumPy arrays (points, normals, + transforms), never as raw `gp_*` objects. + +To implement new geometry behavior you follow the same pattern every method uses: +get the raw object with `.topods_shape()`, operate on it with `OCC.Core.*`, and +wrap the result back into a PyOCC class with `Shape.pyocc_shape(result)`. + +```text ++-------------------+ .topods_shape() +------------------------+ +| PyOCC wrapper | -----------------------> | OCC.Core operation | +| (Solid, Face, …) | | (BRepAlgoAPI, etc.) | +| | <----------------------- | | ++-------------------+ Shape.pyocc_shape() +------------------------+ +``` + +### The `pyocc_shape` factory + +`Shape.pyocc_shape(topods_shape)` (alias `Shape.create_from_topods`) inspects +`ShapeType()` and returns the matching wrapper. A shape obtained from a file +reader or a topology explorer reports the correct `ShapeType()` but is still a +plain `TopoDS_Shape`, so the factory **down-casts** it (`topods.Solid(...)`, +`topods.Compound(...)`, …) before constructing the wrapper — the concrete +wrappers' `isinstance` checks require the specific type. + +## Shape hierarchy + +Every concrete shape subclasses `Shape` (in `shape.py`) and additionally mixes in +only the container mixins that `Shape` does **not** already provide. + +```text +Shape(FaceContainerMixin, EdgeContainerMixin, BoundingBoxMixin) + | + +-- Vertex(Shape) + +-- Edge(Shape, VertexContainerMixin) + +-- Wire(Shape, VertexContainerMixin) + +-- Face(Shape, EdgeContainerMixin, VertexContainerMixin, WireContainerMixin, + | SurfacePropertiesMixin, BoundingBoxMixin) + +-- Shell(Shape, VertexContainerMixin, WireContainerMixin, SurfacePropertiesMixin) + +-- Solid(Shape, VertexContainerMixin, WireContainerMixin, ShellContainerMixin, + | SurfacePropertiesMixin, VolumePropertiesMixin, TriangulatorMixin, + | BottomUpFaceIterator, BottomUpEdgeIterator) + +-- Compound(Shape, BottomUpFaceIterator, BottomUpEdgeIterator, + ShellContainerMixin, SolidContainerMixin, SurfacePropertiesMixin, + TriangulatorMixin, VertexContainerMixin, VolumePropertiesMixin, + WireContainerMixin) +``` + +> **MRO rule:** because `Shape` already linearizes +> `FaceContainerMixin -> EdgeContainerMixin -> BoundingBoxMixin`, a subclass must +> **not** re-list those mixins. Doing so forces a conflicting ordering and Python +> raises a C3 `TypeError: Cannot create a consistent MRO`. List only the *other* +> mixins the type needs. + +Operations shared by all shapes live on `Shape`: `translate`, `scale`, +`rotate_axis_angle`, `transform`, `valid`, `reversed`, `find_closest_point_data`, +`save_to_occ_native`. Add cross-shape behavior there. To add a capability to a +*subset* of shapes, add or reuse a mixin in `base.py`. + +### Mixin composition (`base.py`) + +`base.py` holds the reusable capability mixins: + +| Mixin | Provides | +| ----- | -------- | +| `VertexContainerMixin` / `EdgeContainerMixin` / `FaceContainerMixin` / `WireContainerMixin` / `ShellContainerMixin` / `SolidContainerMixin` | De-duplicated iteration and counts over sub-shapes of a given type, plus `*_from_*` connectivity queries | +| `BoundingBoxMixin` | Axis-aligned bounding box (`box()`) | +| `SurfacePropertiesMixin` | `area()`, `center_of_mass()`, moments | +| `VolumePropertiesMixin` | `volume()` and mass properties | +| `TriangulatorMixin` | `triangulate_all_faces()`, `get_triangles()` | +| `BottomUpFaceIterator` / `BottomUpEdgeIterator` | Adjacency iteration from a child up to its parents | +| `TopologyUtils` | Static helpers for raw `TopExp_Explorer` traversal | + +Container iteration uses `TopTools_IndexedMapOfShape` to deduplicate — a raw +`TopExp_Explorer` over a solid visits each edge once per adjacent face, so any +connectivity query must dedupe to avoid double counting. + +### Circular-import discipline + +The shape modules reference each other, which would create import cycles if done +naively. The codebase resolves this in two ways: + +- `base.py` guards cross-shape imports behind `TYPE_CHECKING`. +- `shape.py` imports the concrete wrappers **lazily inside `pyocc_shape()`**, so + the wrapper modules can safely `from .shape import Shape` at module top level. + +When adding imports between shape modules, follow this pattern; a naive top-level +`from .face import Face` can reintroduce a cycle. + +## Uniform contracts + +### `find_closest_point_data` + +Every shape returns the same 3-tuple, indexable as `[0] / [1] / [2]`: + +```text +(point: np.ndarray, middle, distance: float) +``` + +`middle` is the curve parameter (Edge/Wire), the closest sub-`Face` (Solid / +Face / Shell), or `None` (Vertex). It is never a dict. + +## Subsystem map (`src/pyocc/`) + +- **Topology core** — `shape.py`, `vertex.py`, `edge.py`, `wire.py`, `face.py`, + `shell.py`, `solid.py`, `compound.py`: the shape hierarchy plus Boolean, + transform, and query operations. +- **Parametric design** — `plane.py`, `sketch.py`, `constraints.py` + (`Constraint` / `ConstraintType` sketch constraints). +- **Analysis & identity** — `comparison.py` (shape hashing, `shapes_equal`, + `shape_similarity`), `entity_mapper.py`, `edge_data_extractor.py`, `graph.py` + (NetworkX face/vertex adjacency), `analysis/section.py` (planar sectioning), + `uvgrid.py` (surface/curve sampling, `UniformGrid`). +- **Visualization** — `viewer.py` (interactive desktop viewer + `OffscreenRenderer`), + `jupyter_viewer.py` (WebGL notebook widget), `display_data.py` (display-state + dataclasses), `batch/render_multiview.py` (multi-view batch rendering). +- **I/O** — `io.py`: STEP / IGES / STL / native BREP load and save. +- **Context** — `context.py`: `PyOCCContext` (tolerances, deflections, coordinate + system) scoped by `PyOCCContextManager`. +- **ParaQL** — `ast.py`: a self-contained SQL-like *Parametric Query Language* + (`QueryParser`, `parse_query`, a full SELECT/JOIN/WHERE/aggregation AST). It is + exported from the package but not yet wired into shape querying. +- **Geometry utilities** — `geometry/`: `interval.py` (1D `Interval`, accessors + `.a`/`.b`), `box.py` (`Box`), `arc_length_param_finder.py`, `tri_utils.py`, + `geom_utils.py` (coordinate conversion), `mesh_utils.py`. + +## Environment note + +`pythonocc-core` ships only through conda-forge, so PyOCC must run inside a +conda/mamba environment built from `environment.yml`. `pip install -e .` installs +the pure-Python dependencies and the package itself but **not** OpenCASCADE. See +the [developer guide](developer_guide.md) for setup. diff --git a/docs/developer_guide.md b/docs/developer_guide.md new file mode 100644 index 0000000..d14e5b7 --- /dev/null +++ b/docs/developer_guide.md @@ -0,0 +1,128 @@ +# PyOCC Developer Guide + +A practical guide to working on PyOCC: environment setup, the test/lint loop, and +how to extend the library without tripping over its conventions. + +## Environment setup + +`pythonocc-core` (the OpenCASCADE bindings) is distributed **only through +conda-forge** — it is not pip-installable. Develop inside a conda/mamba +environment: + +```bash +mamba env create -f environment.yml # creates the `pyocc` env (conda also works) +mamba activate pyocc +pip install -e . # install PyOCC itself, editable +``` + +`environment.yml` pins only `python>=3.9`, so a fresh solve currently resolves to +a recent Python (3.14) and OpenCASCADE 7.9.x. The package targets Python +**>=3.10** (`pyproject.toml`). + +> Use `mamba env create -f environment.yml`. The common mistake +> `mamba create env pyocc` passes `env` and `pyocc` as *package names*. + +## The test / lint loop + +```bash +pytest # full suite +pytest tests/src/pyocc/solid_test.py # one file +pytest tests/src/pyocc/solid_test.py::SolidTest::test_make_box # one test +pytest --cov=pyocc tests/ # coverage + +ruff check src tests # lint + import order (add --fix to sort imports) +black src tests # format (line length 100) +pymarkdown scan README.md docs examples # markdown lint (rules in pyproject) +mypy src/pyocc # type check (lenient; OCC.* ignored) +``` + +A change is complete only when `pytest` is green with no new failures. Add a +regression test for every bug fix and a test for every behavior change. + +### Test conventions + +Tests live under `tests/src/pyocc/` mirroring `src/pyocc/`, are named `*_test.py`, +and are mostly `unittest.TestCase` subclasses (a few use plain `pytest` classes +with `setup_method`). They build OCC primitives directly and assert on real +geometry/topology. **Assert on values, not just shapes** — a bare-`except` +fallback elsewhere in the code can silently mask a broken computation that a +shape-only assertion would miss. + +## Project layout + +```text +src/pyocc/ # the package (src layout; installed via pip install -e .) + shape.py # Shape base class + pyocc_shape() factory + base.py # capability mixins (containers, properties, triangulator) + {vertex,edge,wire,face,shell,solid,compound}.py # concrete shapes + io.py # STEP / IGES / STL / BREP load & save + viewer.py, jupyter_viewer.py, display_data.py, batch/ # visualization + graph.py, comparison.py, entity_mapper.py, uvgrid.py # analysis + analysis/section.py # planar sectioning + context.py # tolerances / coordinate-system context + ast.py # ParaQL query language + geometry/ # Interval, Box, mesh & coordinate utilities +tests/src/pyocc/ # tests, mirroring src/pyocc/ +examples/ # runnable example scripts +docs/ # this documentation +``` + +There must **not** be an `__init__.py` at the repository root — it would shadow +the installed `src/pyocc` package during pytest collection. + +## Extending PyOCC + +### Add a method shared by all shapes + +Put it on `Shape` (`shape.py`). It will be available on every wrapper. Use +`self._shape` for the raw object and `Shape.pyocc_shape(result)` to wrap results. + +### Add a capability for a subset of shapes + +Add (or reuse) a mixin in `base.py` and mix it into the relevant classes. Do +**not** re-list `FaceContainerMixin`, `EdgeContainerMixin`, or `BoundingBoxMixin` +in a subclass — `Shape` already provides them and re-listing breaks the MRO. + +### Add a new shape type + +1. Create `src/pyocc/.py` with `from .shape import Shape` and the + type-specific mixins it needs. +2. Set `self._shape` in `__init__` (validate the `TopoDS_*` type). +3. Register it in `Shape.pyocc_shape()` — remember to **down-cast** + (`topods.(topods_shape)`) before constructing the wrapper. +4. Export it from `src/pyocc/__init__.py`. +5. Add a `tests/src/pyocc/_test.py`. + +## Gotchas (mostly OpenCASCADE version drift) + +The codebase was originally written against an older OCC/numpy. When touching +these areas, prefer the patterns already proven elsewhere in the tree: + +- **Triangulation:** read a face's mesh with `BRep_Tool.Triangulation(face, + location)` and the `NbNodes()/Node(i)` + `NbTriangles()/Triangle(i).Get()` + accessors (the pre-7.6 `Nodes()`/`IsNull()` API is gone). See + `geometry/mesh_utils.py` and `base.py:get_triangles`. +- **Down-casting:** a shape from a reader/explorer is a generic `TopoDS_Shape`; + down-cast with `topods.Solid(...)` etc. before passing it to a wrapper whose + `__init__` checks `isinstance`. +- **`Interval`** exposes `.a` / `.b` (and `middle()`, `length()`, + `interpolate()`, `normalize()`) — there is no `.start` / `.end`. +- **Dataclasses:** Python 3.11+ rejects mutable defaults; use + `field(default_factory=lambda: np.array([...]))` for array fields. + +## Continuous integration + +Three workflows live in `.github/workflows/`: + +- **`check.yml`** — provisions the conda env with micromamba; gates on `ruff` + (lint + import order), `black --check`, and `pymarkdown` (markdown lint); runs + `mypy` informationally; and runs `pytest` under Xvfb (a virtual display) so the + offscreen-render tests execute. +- **`build.yml`** — builds the sdist + wheel, validates them with `twine`, and + smoke-imports the built wheel inside a conda env. +- **`publish.yml`** — on a published GitHub Release, builds and uploads to PyPI + via Trusted Publishing (OIDC). See the comments in that file for the one-time + PyPI setup. + +`ruff` and `black` are blocking; only `mypy` remains informational while the +OCC-heavy code's type debt is worked down. diff --git a/environment.yml b/environment.yml index c90baab..884c3a2 100644 --- a/environment.yml +++ b/environment.yml @@ -36,3 +36,4 @@ dependencies: - pytest-cov - pytest-benchmark - pykdtree + - pymarkdownlnt diff --git a/examples/01_basic_shapes.py b/examples/01_basic_shapes.py new file mode 100644 index 0000000..8c75c8c --- /dev/null +++ b/examples/01_basic_shapes.py @@ -0,0 +1,54 @@ +""" +Basic shape creation, geometric properties, and Boolean operations. + +Run with the `pyocc` conda environment active: + + python examples/01_basic_shapes.py +""" + +import numpy as np + +from pyocc.solid import Solid + + +def main(): + # --- Create primitive solids ------------------------------------------- + box = Solid.make_box(10, 20, 30) + sphere = Solid.make_sphere(radius=15, center=np.array([25, 0, 0])) + cylinder = Solid.make_cylinder(radius=5, height=40) + + for name, solid in [("box", box), ("sphere", sphere), ("cylinder", cylinder)]: + print(f"{name:9s} volume={solid.volume():10.2f} area={solid.area():10.2f}") + + # --- Geometric properties ---------------------------------------------- + bbox = box.box() + print("\nbox center of mass :", np.round(box.center_of_mass(), 3)) + print("box bounding min :", np.round(bbox.min, 3)) + print("box bounding max :", np.round(bbox.max, 3)) + print("box is valid :", box.valid()) + print("box faces/edges/verts:", box.num_faces(), box.num_edges(), box.num_vertices()) + + # --- Boolean operations ------------------------------------------------ + overlap_box = Solid.make_box(10, 10, 10) + overlap_sphere = Solid.make_sphere(radius=6, center=np.array([10, 5, 5])) + + union = Solid.union(overlap_box, overlap_sphere) + difference = Solid.difference(overlap_box, overlap_sphere) + intersection = Solid.intersection(overlap_box, overlap_sphere) + + print("\nBoolean results (volume):") + print(f" union : {union.volume():.2f}") + print(f" difference : {difference.volume():.2f}") + print(f" intersection : {intersection.volume():.2f}") + + # --- Transformations --------------------------------------------------- + moved = box.translate(np.array([100, 0, 0])) + scaled = box.scale(2.0) + rotated = box.rotate_axis_angle(axis=(0, 0, 1), angle=np.pi / 4) + print("\ntranslated center :", np.round(moved.center_of_mass(), 3)) + print("scaled volume :", round(scaled.volume(), 2), "(8x original)") + print("rotated is valid :", rotated.valid()) + + +if __name__ == "__main__": + main() diff --git a/examples/02_geometry_analysis.py b/examples/02_geometry_analysis.py new file mode 100644 index 0000000..0809344 --- /dev/null +++ b/examples/02_geometry_analysis.py @@ -0,0 +1,64 @@ +""" +Surface, curve, and topology analysis. + +Run with the `pyocc` conda environment active: + + python examples/02_geometry_analysis.py +""" + +import numpy as np + +from pyocc.edge import Edge +from pyocc.solid import Solid + + +def analyze_faces(box): + print("Face analysis") + print("-------------") + for i, face in enumerate(box.faces()): + # Evaluate the surface at the centre of its UV domain. + u_min, u_max, v_min, v_max = face.uv_bounds() + u_mid, v_mid = 0.5 * (u_min + u_max), 0.5 * (v_min + v_max) + + point = face.point(np.array([u_mid, v_mid])) + normal = face.normal(np.array([u_mid, v_mid])) + print( + f" face {i}: area={face.area():7.2f} centre={np.round(point, 2)} " + f"normal={np.round(normal, 2)}" + ) + + +def closest_point(box): + print("\nClosest-point query") + print("-------------------") + datum = np.array([20.0, 0.0, 0.0]) # outside the 10x20x30 box (max X = 10) + point, element, distance = box.find_closest_point_data(datum) + print(f" datum : {datum}") + print(f" closest : {np.round(point, 3)}") + print(f" distance : {distance:.3f}") + print(f" on a : {type(element).__name__}") + + +def analyze_edges(): + print("\nCurve analysis") + print("--------------") + line = Edge.make_line_from_points(np.array([0, 0, 0]), np.array([10, 10, 0])) + print(f" line type : {line.curve_type()} length={line.length():.4f}") + print(f" midpoint : {np.round(line.point(line.u_bounds().middle()), 3)}") + + circle = Edge.make_circle(center=np.array([0, 0, 0]), radius=5.0) + print( + f" circle type : {circle.curve_type()} length={circle.length():.4f} " + f"(2*pi*r = {2 * np.pi * 5:.4f})" + ) + + +def main(): + box = Solid.make_box(10, 20, 30) + analyze_faces(box) + closest_point(box) + analyze_edges() + + +if __name__ == "__main__": + main() diff --git a/examples/03_uvgrid_sampling.py b/examples/03_uvgrid_sampling.py new file mode 100644 index 0000000..072c0cd --- /dev/null +++ b/examples/03_uvgrid_sampling.py @@ -0,0 +1,49 @@ +""" +Uniform UV-grid sampling of faces and edges. + +Run with the `pyocc` conda environment active: + + python examples/03_uvgrid_sampling.py +""" + +import numpy as np + +from pyocc.solid import Solid +from pyocc.uvgrid import UniformGrid, sample_edge_uniformly, sample_face_uniformly + + +def main(): + box = Solid.make_box(2.0, 2.0, 2.0) + face = next(box.faces()) + edge = next(box.edges()) + + # --- UniformGrid object over a face ------------------------------------ + grid = UniformGrid(face, u_count=8, v_count=5) + points = grid.get_points() + normals = grid.get_normals() + u_params, v_params = grid.get_parameters() + + print("UniformGrid(face, u_count=8, v_count=5)") + print(f" points shape : {points.shape} (8 * 5 = 40 samples)") + print(f" normals shape : {normals.shape}") + print(f" u parameters : {np.round(u_params, 3)}") + print(f" v parameters : {np.round(v_params, 3)}") + print(f" normals unit? : {np.allclose(np.linalg.norm(normals, axis=1), 1.0)}") + + # --- Functional face sampling ------------------------------------------ + face_data = sample_face_uniformly(face, num_u=4, num_v=4) + print("\nsample_face_uniformly(face, 4, 4)") + print(f" keys : {sorted(face_data)}") + print(f" points : {face_data['points'].shape}") + + # --- Functional edge sampling ------------------------------------------ + edge_data = sample_edge_uniformly(edge, num_points=10) + print("\nsample_edge_uniformly(edge, 10)") + print(f" keys : {sorted(edge_data)}") + print(f" points : {edge_data['points'].shape}") + print(f" tangents unit?: {np.allclose(np.linalg.norm(edge_data['tangents'], axis=1), 1.0)}") + print(f" params ascend?: {np.all(np.diff(edge_data['parameters']) >= 0)}") + + +if __name__ == "__main__": + main() diff --git a/examples/04_file_io.py b/examples/04_file_io.py new file mode 100644 index 0000000..5523d42 --- /dev/null +++ b/examples/04_file_io.py @@ -0,0 +1,51 @@ +""" +File I/O: write and read STEP, STL, and native BREP formats. + +Run with the `pyocc` conda environment active: + + python examples/04_file_io.py +""" + +import tempfile +from pathlib import Path + +from pyocc.io import load_brep, load_step, load_stl, save_brep, save_step, save_stl +from pyocc.solid import Solid + + +def main(): + box = Solid.make_box(10, 20, 30) + + with tempfile.TemporaryDirectory() as tmp_name: + tmp = Path(tmp_name) + + # STEP -- industry-standard CAD exchange (exact B-rep) -------------- + step_path = tmp / "box.step" + save_step(box, step_path) + step_shape = load_step(step_path) + print( + f"STEP -> {step_path.name:9s} {step_path.stat().st_size:6d} bytes " + f"reloaded faces={step_shape.num_faces()}" + ) + + # STL -- triangle mesh for 3D printing (tessellated) ---------------- + stl_path = tmp / "box.stl" + save_stl(box, stl_path, linear_deflection=0.1) + stl_shape = load_stl(stl_path) + print( + f"STL -> {stl_path.name:9s} {stl_path.stat().st_size:6d} bytes " + f"reloaded faces={stl_shape.num_faces()} (mesh triangles)" + ) + + # BREP -- native OpenCASCADE binary (fastest, lossless) ------------- + brep_path = tmp / "box.brep" + save_brep(box, brep_path) + brep_shape = load_brep(brep_path) + print( + f"BREP -> {brep_path.name:9s} {brep_path.stat().st_size:6d} bytes " + f"reloaded faces={brep_shape.num_faces()}" + ) + + +if __name__ == "__main__": + main() diff --git a/examples/05_topology_graph.py b/examples/05_topology_graph.py new file mode 100644 index 0000000..73af6ff --- /dev/null +++ b/examples/05_topology_graph.py @@ -0,0 +1,34 @@ +""" +Topological adjacency graphs (uses networkx, included in the environment). + +Run with the `pyocc` conda environment active: + + python examples/05_topology_graph.py +""" + +from pyocc.graph import face_adjacency, vertex_adjacency +from pyocc.solid import Solid + + +def main(): + box = Solid.make_box(10, 20, 30) + + # Faces become nodes; an arc joins two faces that share an edge. + face_graph = face_adjacency(box) + face_degrees = [d for _, d in face_graph.degree()] + print("Face adjacency graph (box)") + print(f" faces (nodes) : {face_graph.number_of_nodes()}") + print(f" shared edges (arcs) : {face_graph.number_of_edges()}") + print(f" neighbours per face : {min(face_degrees)}..{max(face_degrees)}") + + # Vertices become nodes; an arc joins two vertices sharing an edge. + vertex_graph = vertex_adjacency(box) + vertex_degrees = [d for _, d in vertex_graph.degree()] + print("\nVertex adjacency graph (box)") + print(f" vertices (nodes) : {vertex_graph.number_of_nodes()}") + print(f" box edges (arcs) : {vertex_graph.number_of_edges()}") + print(f" edges per vertex : {min(vertex_degrees)}..{max(vertex_degrees)}") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4a24269 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,21 @@ +# PyOCC Examples + +Runnable examples demonstrating the PyOCC API. Each script is self-contained and +prints its results to the console. Run them with the `pyocc` conda environment +active (see the [installation guide](../README.md#-installation)): + +```bash +mamba activate pyocc +python examples/01_basic_shapes.py +``` + +| Script | Demonstrates | +| ------ | ------------ | +| [`01_basic_shapes.py`](01_basic_shapes.py) | Creating primitives, geometric properties (volume, area, centre of mass, bounding box), Boolean operations, and transformations | +| [`02_geometry_analysis.py`](02_geometry_analysis.py) | Per-face surface evaluation (points & normals), closest-point queries, and curve (edge) analysis | +| [`03_uvgrid_sampling.py`](03_uvgrid_sampling.py) | Uniform UV-grid sampling of faces and edges via `UniformGrid`, `sample_face_uniformly`, and `sample_edge_uniformly` | +| [`04_file_io.py`](04_file_io.py) | Writing and reading STEP, STL, and native BREP files | +| [`05_topology_graph.py`](05_topology_graph.py) | Building face- and vertex-adjacency graphs (NetworkX) and inspecting their connectivity | + +All scripts are exercised the same way you'd run them here, so they double as a +quick smoke test of the core API. diff --git a/pyproject.toml b/pyproject.toml index d9d91c4..dd46783 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,8 +41,9 @@ dev = [ "pytest>=7.4.0", "pytest-cov>=4.1.0", "black>=23.0.0", - "isort>=5.12.0", + "ruff>=0.1.0", "mypy>=1.5.0", + "pymarkdownlnt>=0.9.0", ] test = [ @@ -61,9 +62,23 @@ include = ["pyocc*"] line-length = 100 target-version = ['py310'] -[tool.isort] -profile = "black" -line_length = 100 +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +# Ruff's defaults (E4/E7/E9 + F) plus import sorting (I). Ruff's isort is +# black-compatible, so it does not oscillate with `black` the way standalone +# isort does around `if TYPE_CHECKING:` blocks. +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.lint.isort] +known-first-party = ["pyocc"] + +[tool.pymarkdown] +# Line length (MD013) is not meaningful for rendered prose/markdown, so it is +# disabled; every other markdownlint rule is enforced. +plugins.md013.enabled = false [tool.pytest.ini_options] testpaths = ["tests"] @@ -94,6 +109,6 @@ reportMissingImports = false reportMissingTypeStubs = false [project.urls] -Homepage = "https://github.com/layerdynamics/pyocc" -Repository = "https://github.com/layerdynamics/pyocc" -"Bug Tracker" = "https://github.com/layerdynamics/pyocc/issues" \ No newline at end of file +Homepage = "https://github.com/LatticeLabsAI/pyocc" +Repository = "https://github.com/LatticeLabsAI/pyocc" +"Bug Tracker" = "https://github.com/LatticeLabsAI/pyocc/issues" \ No newline at end of file diff --git a/src/pyocc/__init__.py b/src/pyocc/__init__.py index 8a2b3e4..5f52b36 100644 --- a/src/pyocc/__init__.py +++ b/src/pyocc/__init__.py @@ -9,120 +9,244 @@ that use mixins to provide reusable functionality across different geometric entities. """ -__version__ = '0.1.0' +__version__ = "0.1.0" -# Core shape classes -from .shape import Shape -from .vertex import Vertex -from .edge import Edge -from .wire import Wire -from .face import Face -from .shell import Shell -from .solid import Solid -from .compound import Compound +# Analysis modules +from .analysis.section import ( + SectionResult, + SectionType, + analyze_section_properties, + create_multiple_sections, + create_planar_section, + find_section_at_point, + section_along_curve, +) -# Parametric design classes -from .plane import Plane -from .sketch import Sketch +# AST and query parsing +from .ast import ( + AggregationExpression, + AggregationType, + BinaryExpression, + ColumnReference, + Expression, + FromClause, + FunctionCall, + GroupByClause, + HavingClause, + JoinClause, + JoinType, + LimitClause, + Literal, + OperatorType, + OrderByClause, + Query, + QueryParser, + QueryType, + SelectClause, + UnaryExpression, + WhereClause, + aggregate, + binary_op, + column, + function, + literal, + parse_query, + query_to_dict, + query_to_string, + unary_op, +) +from .base import ( + BoundingBoxMixin, + EdgeContainerMixin, + FaceContainerMixin, + ShellContainerMixin, + SolidContainerMixin, + TopologyUtils, + TriangulatorMixin, + VertexContainerMixin, + WireContainerMixin, +) + +# Batch processing +from .batch.render_multiview import ( + BatchProcessor, + MultiViewRenderer, + RenderSettings, + ViewAngle, + create_comparison_grid, + create_documentation_images, + render_animation_frames, +) +from .comparison import ( + ComparisonLevel, + ShapeFingerprint, + compute_shape_hash, + shape_similarity, + shapes_equal, +) +from .compound import Compound from .constraints import Constraint, ConstraintType +from .context import PyOCCContext, PyOCCContextManager +from .display_data import DisplayEdge, DisplayFace, DisplayMaterial, DisplayShape, DisplayVertex +from .edge import Edge # Analysis and utilities from .edge_data_extractor import EdgeDataExtractor from .entity_mapper import EntityMapper -from .comparison import ComparisonLevel, ShapeFingerprint, compute_shape_hash, shapes_equal, shape_similarity -from .display_data import DisplayShape, DisplayMaterial, DisplayVertex, DisplayEdge, DisplayFace -from .base import ( - TopologyUtils, VertexContainerMixin, EdgeContainerMixin, FaceContainerMixin, - WireContainerMixin, ShellContainerMixin, SolidContainerMixin, - BoundingBoxMixin, TriangulatorMixin -) - -# I/O operations -from .io import save_step, load_step, save_stl, load_stl, save_brep, load_brep, save_iges, load_iges - -# Visualization -from .viewer import Viewer, OffscreenRenderer, create_viewer, display_shape -from .jupyter_viewer import JupyterViewer +from .face import Face -# Grid and mesh utilities -from .uvgrid import uvgrid, ugrid, uvgrid_points, uvgrid_normals +# Geometry utilities +from .geometry import ArcLengthParamFinder, Box, Interval # Graph analysis -from .graph import TopologyGraph, FaceAdjacencyGraph, VertexAdjacencyGraph +from .graph import FaceAdjacencyGraph, TopologyGraph, VertexAdjacencyGraph -# Geometry utilities -from .geometry import Interval, Box, ArcLengthParamFinder +# I/O operations +from .io import load_brep, load_iges, load_step, load_stl, save_brep, save_iges, save_step, save_stl +from .jupyter_viewer import JupyterViewer -# AST and query parsing -from .ast import ( - Query, QueryType, Expression, Literal, ColumnReference, BinaryExpression, - UnaryExpression, FunctionCall, AggregationExpression, SelectClause, - FromClause, JoinClause, WhereClause, GroupByClause, HavingClause, - OrderByClause, LimitClause, OperatorType, AggregationType, JoinType, - QueryParser, parse_query, query_to_dict, query_to_string, - literal, column, function, aggregate, binary_op, unary_op -) +# Parametric design classes +from .plane import Plane -# Analysis modules -from .analysis.section import ( - SectionResult, SectionType, create_planar_section, analyze_section_properties, - create_multiple_sections, find_section_at_point, section_along_curve -) +# Core shape classes +from .shape import Shape +from .shell import Shell +from .sketch import Sketch +from .solid import Solid -# Batch processing -from .batch.render_multiview import ( - MultiViewRenderer, RenderSettings, ViewAngle, BatchProcessor, - create_documentation_images, create_comparison_grid, render_animation_frames +# Grid and mesh utilities +from .uvgrid import ( + UniformGrid, + sample_edge_uniformly, + sample_face_uniformly, + ugrid, + uvgrid, + uvgrid_normals, + uvgrid_points, ) +from .vertex import Vertex -from .context import PyOCCContext, PyOCCContextManager +# Visualization +from .viewer import OffscreenRenderer, Viewer, create_viewer, display_shape +from .wire import Wire __all__ = [ # Core shapes - 'Shape', 'Vertex', 'Edge', 'Wire', 'Face', 'Shell', 'Solid', 'Compound', - + "Shape", + "Vertex", + "Edge", + "Wire", + "Face", + "Shell", + "Solid", + "Compound", # Parametric design - 'Plane', 'Sketch', 'Constraint', 'ConstraintType', - + "Plane", + "Sketch", + "Constraint", + "ConstraintType", # Analysis and utilities - 'EdgeDataExtractor', 'EntityMapper', 'ComparisonLevel', 'ShapeFingerprint', - 'Edge', - 'compute_shape_hash', 'shapes_equal', 'shape_similarity', - 'DisplayShape', 'DisplayMaterial', 'DisplayVertex', 'DisplayEdge', 'DisplayFace', - 'TopologyUtils', 'VertexContainerMixin', 'EdgeContainerMixin', 'FaceContainerMixin', - 'WireContainerMixin', 'ShellContainerMixin', 'SolidContainerMixin', - 'BoundingBoxMixin', 'TriangulatorMixin', - + "EdgeDataExtractor", + "EntityMapper", + "ComparisonLevel", + "ShapeFingerprint", + "Edge", + "compute_shape_hash", + "shapes_equal", + "shape_similarity", + "DisplayShape", + "DisplayMaterial", + "DisplayVertex", + "DisplayEdge", + "DisplayFace", + "TopologyUtils", + "VertexContainerMixin", + "EdgeContainerMixin", + "FaceContainerMixin", + "WireContainerMixin", + "ShellContainerMixin", + "SolidContainerMixin", + "BoundingBoxMixin", + "TriangulatorMixin", # I/O operations - 'save_step', 'load_step', 'save_stl', 'load_stl', 'save_brep', 'load_brep', - 'save_iges', 'load_iges', - + "save_step", + "load_step", + "save_stl", + "load_stl", + "save_brep", + "load_brep", + "save_iges", + "load_iges", # Visualization - 'Viewer', 'OffscreenRenderer', 'create_viewer', 'display_shape', - 'JupyterViewer', - + "Viewer", + "OffscreenRenderer", + "create_viewer", + "display_shape", + "JupyterViewer", # Grid and mesh utilities - 'uvgrid', 'ugrid', 'uvgrid_points', 'uvgrid_normals', - + "uvgrid", + "ugrid", + "uvgrid_points", + "uvgrid_normals", + "UniformGrid", + "sample_face_uniformly", + "sample_edge_uniformly", # Graph analysis - 'TopologyGraph', 'FaceAdjacencyGraph', 'VertexAdjacencyGraph', - + "TopologyGraph", + "FaceAdjacencyGraph", + "VertexAdjacencyGraph", # Geometry utilities - 'Interval', 'Box', 'ArcLengthParamFinder', - + "Interval", + "Box", + "ArcLengthParamFinder", # AST and query parsing - 'Query', 'QueryType', 'Expression', 'Literal', 'ColumnReference', 'BinaryExpression', - 'UnaryExpression', 'FunctionCall', 'AggregationExpression', 'SelectClause', - 'FromClause', 'JoinClause', 'WhereClause', 'GroupByClause', 'HavingClause', - 'OrderByClause', 'LimitClause', 'OperatorType', 'AggregationType', 'JoinType', - 'QueryParser', 'parse_query', 'query_to_dict', 'query_to_string', - 'literal', 'column', 'function', 'aggregate', 'binary_op', 'unary_op', - + "Query", + "QueryType", + "Expression", + "Literal", + "ColumnReference", + "BinaryExpression", + "UnaryExpression", + "FunctionCall", + "AggregationExpression", + "SelectClause", + "FromClause", + "JoinClause", + "WhereClause", + "GroupByClause", + "HavingClause", + "OrderByClause", + "LimitClause", + "OperatorType", + "AggregationType", + "JoinType", + "QueryParser", + "parse_query", + "query_to_dict", + "query_to_string", + "literal", + "column", + "function", + "aggregate", + "binary_op", + "unary_op", # Analysis modules - 'SectionResult', 'SectionType', 'create_planar_section', 'analyze_section_properties', - 'create_multiple_sections', 'find_section_at_point', 'section_along_curve', - + "SectionResult", + "SectionType", + "create_planar_section", + "analyze_section_properties", + "create_multiple_sections", + "find_section_at_point", + "section_along_curve", # Batch processing - 'MultiViewRenderer', 'RenderSettings', 'ViewAngle', 'BatchProcessor', - 'create_documentation_images', 'create_comparison_grid', 'render_animation_frames', -] \ No newline at end of file + "MultiViewRenderer", + "RenderSettings", + "ViewAngle", + "BatchProcessor", + "create_documentation_images", + "create_comparison_grid", + "render_animation_frames", + # Context + "PyOCCContext", + "PyOCCContextManager", +] diff --git a/src/pyocc/analysis/__init__.py b/src/pyocc/analysis/__init__.py index 506ce48..8089d66 100644 --- a/src/pyocc/analysis/__init__.py +++ b/src/pyocc/analysis/__init__.py @@ -8,19 +8,19 @@ from .section import ( SectionResult, SectionType, - create_planar_section, - create_multiple_sections, analyze_section_properties, + create_multiple_sections, + create_planar_section, find_section_at_point, - section_along_curve + section_along_curve, ) __all__ = [ - 'SectionResult', - 'SectionType', - 'create_planar_section', - 'create_multiple_sections', - 'analyze_section_properties', - 'find_section_at_point', - 'section_along_curve' + "SectionResult", + "SectionType", + "create_planar_section", + "create_multiple_sections", + "analyze_section_properties", + "find_section_at_point", + "section_along_curve", ] diff --git a/src/pyocc/analysis/section.py b/src/pyocc/analysis/section.py index 3c07790..390eac2 100644 --- a/src/pyocc/analysis/section.py +++ b/src/pyocc/analysis/section.py @@ -4,36 +4,34 @@ This module provides tools for creating cross-sections of shapes, analyzing sectional properties, and computing geometric properties of sections. """ -import numpy as np -from typing import List, Optional, Union, Tuple, TYPE_CHECKING + from enum import Enum +from typing import TYPE_CHECKING, List, Optional, Tuple -from OCC.Core.gp import gp_Pln, gp_Pnt, gp_Dir, gp_Vec, gp_Ax1, gp_Ax2 -from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Section -from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Edge, TopoDS_Wire, TopoDS_Face -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_WIRE, TopAbs_VERTEX -from OCC.Core.BRep import BRep_Tool -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeFace -from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnCurve +import numpy as np from OCC.Core.BRepAdaptor import BRepAdaptor_Curve -from OCC.Core.GCPnts import GCPnts_AbscissaPoint -from OCC.Core.GProp import GProp_GProps +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Section +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace from OCC.Core.BRepGProp import brepgprop_LinearProperties, brepgprop_SurfaceProperties +from OCC.Core.gp import gp_Dir, gp_Pln, gp_Pnt, gp_Vec +from OCC.Core.GProp import GProp_GProps from OCC.Core.Precision import precision_Confusion +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_WIRE +from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopoDS import TopoDS_Shape if TYPE_CHECKING: - from pyocc.shape import Shape from pyocc.edge import Edge + from pyocc.shape import Shape from pyocc.wire import Wire - from pyocc.face import Face class SectionType(Enum): """Types of cross-sections that can be created.""" - PLANAR = "planar" # Planar cross-section - CYLINDRICAL = "cylindrical" # Cylindrical cross-section - SPHERICAL = "spherical" # Spherical cross-section + + PLANAR = "planar" # Planar cross-section + CYLINDRICAL = "cylindrical" # Cylindrical cross-section + SPHERICAL = "spherical" # Spherical cross-section class SectionResult: @@ -59,10 +57,11 @@ def __init__(self, section_shape: TopoDS_Shape, plane: gp_Pln): self._properties = None @property - def edges(self) -> List['Edge']: + def edges(self) -> List["Edge"]: """Get section edges as pyocc Edge objects.""" if self._edges is None: from pyocc.edge import Edge # Lazy import + self._edges = [] explorer = TopExp_Explorer(self.section_shape, TopAbs_EDGE) while explorer.More(): @@ -72,10 +71,11 @@ def edges(self) -> List['Edge']: return self._edges @property - def wires(self) -> List['Wire']: + def wires(self) -> List["Wire"]: """Get section wires as pyocc Wire objects.""" if self._wires is None: from pyocc.wire import Wire # Lazy import + self._wires = [] explorer = TopExp_Explorer(self.section_shape, TopAbs_WIRE) while explorer.More(): @@ -180,11 +180,11 @@ def bounding_box(self) -> Optional[Tuple[np.ndarray, np.ndarray]]: return None # Project all edge points to the section plane - min_coords = np.array([float('inf'), float('inf')]) - max_coords = np.array([float('-inf'), float('-inf')]) + min_coords = np.array([float("inf"), float("inf")]) + max_coords = np.array([float("-inf"), float("-inf")]) plane_origin = gp_Pnt(self.plane.Location()) - plane_normal = self.plane.Axis().Direction() + self.plane.Axis().Direction() plane_x_dir = self.plane.XAxis().Direction() plane_y_dir = self.plane.YAxis().Direction() @@ -215,8 +215,9 @@ def bounding_box(self) -> Optional[Tuple[np.ndarray, np.ndarray]]: return None -def create_planar_section(shape: 'Shape', plane_origin: np.ndarray, - plane_normal: np.ndarray) -> Optional[SectionResult]: +def create_planar_section( + shape: "Shape", plane_origin: np.ndarray, plane_normal: np.ndarray +) -> Optional[SectionResult]: """ Create a planar cross-section of a shape. @@ -254,9 +255,14 @@ def create_planar_section(shape: 'Shape', plane_origin: np.ndarray, return None -def create_multiple_sections(shape: 'Shape', plane_origin: np.ndarray, - plane_normal: np.ndarray, spacing: float, - count: int, both_directions: bool = False) -> List[SectionResult]: +def create_multiple_sections( + shape: "Shape", + plane_origin: np.ndarray, + plane_normal: np.ndarray, + spacing: float, + count: int, + both_directions: bool = False, +) -> List[SectionResult]: """ Create multiple parallel cross-sections of a shape. @@ -305,20 +311,20 @@ def analyze_section_properties(section: SectionResult) -> dict: Dictionary containing section properties """ properties = { - 'num_edges': section.num_edges, - 'num_wires': section.num_wires, - 'total_length': section.total_length(), - 'perimeter': section.perimeter(), - 'area': section.area(), - 'centroid': section.centroid(), - 'bounding_box': section.bounding_box(), + "num_edges": section.num_edges, + "num_wires": section.num_wires, + "total_length": section.total_length(), + "perimeter": section.perimeter(), + "area": section.area(), + "centroid": section.centroid(), + "bounding_box": section.bounding_box(), } # Additional analysis for closed sections if section.num_wires > 0: closed_wires = [w for w in section.wires if w.is_closed()] - properties['num_closed_wires'] = len(closed_wires) - properties['has_holes'] = len(closed_wires) > 1 + properties["num_closed_wires"] = len(closed_wires) + properties["has_holes"] = len(closed_wires) > 1 if closed_wires: # Calculate area moments for structural analysis @@ -338,11 +344,11 @@ def _calculate_area_moments(section: SectionResult) -> dict: Dictionary with moment calculations """ moments = { - 'moment_of_inertia_x': 0.0, - 'moment_of_inertia_y': 0.0, - 'product_of_inertia': 0.0, - 'section_modulus_x': 0.0, - 'section_modulus_y': 0.0, + "moment_of_inertia_x": 0.0, + "moment_of_inertia_y": 0.0, + "product_of_inertia": 0.0, + "section_modulus_x": 0.0, + "section_modulus_y": 0.0, } # This is a simplified calculation @@ -357,19 +363,20 @@ def _calculate_area_moments(section: SectionResult) -> dict: # Approximate moments for rectangular sections # These are simplified calculations - moments['moment_of_inertia_x'] = area * height**2 / 12.0 - moments['moment_of_inertia_y'] = area * width**2 / 12.0 + moments["moment_of_inertia_x"] = area * height**2 / 12.0 + moments["moment_of_inertia_y"] = area * width**2 / 12.0 if height > 0: - moments['section_modulus_x'] = moments['moment_of_inertia_x'] / (height / 2.0) + moments["section_modulus_x"] = moments["moment_of_inertia_x"] / (height / 2.0) if width > 0: - moments['section_modulus_y'] = moments['moment_of_inertia_y'] / (width / 2.0) + moments["section_modulus_y"] = moments["moment_of_inertia_y"] / (width / 2.0) return moments -def find_section_at_point(shape: 'Shape', point: np.ndarray, - direction: np.ndarray) -> Optional[SectionResult]: +def find_section_at_point( + shape: "Shape", point: np.ndarray, direction: np.ndarray +) -> Optional[SectionResult]: """ Create a cross-section at a specific point with given direction. @@ -384,9 +391,12 @@ def find_section_at_point(shape: 'Shape', point: np.ndarray, return create_planar_section(shape, point, direction) -def section_along_curve(shape: 'Shape', curve_points: np.ndarray, - up_direction: np.ndarray = np.array([0, 0, 1]), - num_sections: int = 10) -> List[SectionResult]: +def section_along_curve( + shape: "Shape", + curve_points: np.ndarray, + up_direction: np.ndarray = np.array([0, 0, 1]), + num_sections: int = 10, +) -> List[SectionResult]: """ Create cross-sections along a 3D curve. @@ -430,11 +440,11 @@ def section_along_curve(shape: 'Shape', curve_points: np.ndarray, # Export all public functions and classes __all__ = [ - 'SectionType', - 'SectionResult', - 'create_planar_section', - 'create_multiple_sections', - 'analyze_section_properties', - 'find_section_at_point', - 'section_along_curve' -] \ No newline at end of file + "SectionType", + "SectionResult", + "create_planar_section", + "create_multiple_sections", + "analyze_section_properties", + "find_section_at_point", + "section_along_curve", +] diff --git a/src/pyocc/ast.py b/src/pyocc/ast.py index fc9a1a8..461d8f4 100644 --- a/src/pyocc/ast.py +++ b/src/pyocc/ast.py @@ -15,6 +15,7 @@ class QueryType(Enum): """Types of ParaQL queries.""" + SELECT = "SELECT" INSERT = "INSERT" UPDATE = "UPDATE" @@ -25,6 +26,7 @@ class QueryType(Enum): class OperatorType(Enum): """Types of operators in ParaQL expressions.""" + EQUAL = "=" NOT_EQUAL = "!=" LESS_THAN = "<" @@ -43,6 +45,7 @@ class OperatorType(Enum): class AggregationType(Enum): """Types of aggregation functions.""" + COUNT = "COUNT" SUM = "SUM" AVG = "AVG" @@ -53,6 +56,7 @@ class AggregationType(Enum): class JoinType(Enum): """Types of joins.""" + INNER = "INNER" LEFT = "LEFT" RIGHT = "RIGHT" @@ -63,12 +67,12 @@ class JoinType(Enum): @dataclass class Expression(ABC): """Base class for all expressions.""" - + @abstractmethod def to_dict(self) -> Dict[str, Any]: """Convert expression to dictionary representation.""" pass - + @abstractmethod def __str__(self) -> str: """Convert expression to string representation.""" @@ -78,15 +82,13 @@ def __str__(self) -> str: @dataclass class Literal(Expression): """Literal value expression.""" + value: Any type: str = "literal" - + def to_dict(self) -> Dict[str, Any]: - return { - "type": self.type, - "value": self.value - } - + return {"type": self.type, "value": self.value} + def __str__(self) -> str: if isinstance(self.value, str): return f"'{self.value}'" @@ -96,17 +98,14 @@ def __str__(self) -> str: @dataclass class ColumnReference(Expression): """Column reference expression.""" + name: str table: Optional[str] = None type: str = "column" - + def to_dict(self) -> Dict[str, Any]: - return { - "type": self.type, - "name": self.name, - "table": self.table - } - + return {"type": self.type, "name": self.name, "table": self.table} + def __str__(self) -> str: if self.table: return f"{self.table}.{self.name}" @@ -116,19 +115,20 @@ def __str__(self) -> str: @dataclass class BinaryExpression(Expression): """Binary operator expression.""" + left: Expression operator: OperatorType right: Expression type: str = "binary" - + def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "left": self.left.to_dict(), "operator": self.operator.value, - "right": self.right.to_dict() + "right": self.right.to_dict(), } - + def __str__(self) -> str: return f"({self.left} {self.operator.value} {self.right})" @@ -136,17 +136,18 @@ def __str__(self) -> str: @dataclass class UnaryExpression(Expression): """Unary operator expression.""" + operator: OperatorType operand: Expression type: str = "unary" - + def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "operator": self.operator.value, - "operand": self.operand.to_dict() + "operand": self.operand.to_dict(), } - + def __str__(self) -> str: return f"{self.operator.value} {self.operand}" @@ -154,17 +155,18 @@ def __str__(self) -> str: @dataclass class FunctionCall(Expression): """Function call expression.""" + name: str arguments: List[Expression] type: str = "function" - + def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "name": self.name, - "arguments": [arg.to_dict() for arg in self.arguments] + "arguments": [arg.to_dict() for arg in self.arguments], } - + def __str__(self) -> str: args_str = ", ".join(str(arg) for arg in self.arguments) return f"{self.name}({args_str})" @@ -173,19 +175,20 @@ def __str__(self) -> str: @dataclass class AggregationExpression(Expression): """Aggregation function expression.""" + function: AggregationType argument: Expression distinct: bool = False type: str = "aggregation" - + def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "function": self.function.value, "argument": self.argument.to_dict(), - "distinct": self.distinct + "distinct": self.distinct, } - + def __str__(self) -> str: distinct_str = "DISTINCT " if self.distinct else "" return f"{self.function.value}({distinct_str}{self.argument})" @@ -194,16 +197,17 @@ def __str__(self) -> str: @dataclass class SelectClause: """SELECT clause of a query.""" + expressions: List[Expression] distinct: bool = False - + def to_dict(self) -> Dict[str, Any]: return { "type": "select", "expressions": [expr.to_dict() for expr in self.expressions], - "distinct": self.distinct + "distinct": self.distinct, } - + def __str__(self) -> str: distinct_str = "DISTINCT " if self.distinct else "" expressions_str = ", ".join(str(expr) for expr in self.expressions) @@ -213,17 +217,15 @@ def __str__(self) -> str: @dataclass class FromClause: """FROM clause of a query.""" + tables: List[Dict[str, Any]] # List of table references with optional aliases - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "from", - "tables": self.tables - } - + return {"type": "from", "tables": self.tables} + def __str__(self) -> str: tables_str = ", ".join( - f"{table['name']} AS {table['alias']}" if table.get('alias') else table['name'] + f"{table['name']} AS {table['alias']}" if table.get("alias") else table["name"] for table in self.tables ) return f"FROM {tables_str}" @@ -232,20 +234,21 @@ def __str__(self) -> str: @dataclass class JoinClause: """JOIN clause of a query.""" + join_type: JoinType table: str alias: Optional[str] = None condition: Optional[Expression] = None - + def to_dict(self) -> Dict[str, Any]: return { "type": "join", "join_type": self.join_type.value, "table": self.table, "alias": self.alias, - "condition": self.condition.to_dict() if self.condition else None + "condition": self.condition.to_dict() if self.condition else None, } - + def __str__(self) -> str: alias_str = f" AS {self.alias}" if self.alias else "" condition_str = f" ON {self.condition}" if self.condition else "" @@ -255,14 +258,12 @@ def __str__(self) -> str: @dataclass class WhereClause: """WHERE clause of a query.""" + condition: Expression - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "where", - "condition": self.condition.to_dict() - } - + return {"type": "where", "condition": self.condition.to_dict()} + def __str__(self) -> str: return f"WHERE {self.condition}" @@ -270,14 +271,12 @@ def __str__(self) -> str: @dataclass class GroupByClause: """GROUP BY clause of a query.""" + expressions: List[Expression] - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "group_by", - "expressions": [expr.to_dict() for expr in self.expressions] - } - + return {"type": "group_by", "expressions": [expr.to_dict() for expr in self.expressions]} + def __str__(self) -> str: expressions_str = ", ".join(str(expr) for expr in self.expressions) return f"GROUP BY {expressions_str}" @@ -286,14 +285,12 @@ def __str__(self) -> str: @dataclass class HavingClause: """HAVING clause of a query.""" + condition: Expression - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "having", - "condition": self.condition.to_dict() - } - + return {"type": "having", "condition": self.condition.to_dict()} + def __str__(self) -> str: return f"HAVING {self.condition}" @@ -301,17 +298,19 @@ def __str__(self) -> str: @dataclass class OrderByClause: """ORDER BY clause of a query.""" + expressions: List[Dict[str, Any]] # List of {expression, direction} dicts - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "order_by", - "expressions": self.expressions - } - + return {"type": "order_by", "expressions": self.expressions} + def __str__(self) -> str: expressions_str = ", ".join( - f"{expr['expression']} {expr['direction']}" if 'direction' in expr else str(expr['expression']) + ( + f"{expr['expression']} {expr['direction']}" + if "direction" in expr + else str(expr["expression"]) + ) for expr in self.expressions ) return f"ORDER BY {expressions_str}" @@ -320,16 +319,13 @@ def __str__(self) -> str: @dataclass class LimitClause: """LIMIT clause of a query.""" + limit: int offset: Optional[int] = None - + def to_dict(self) -> Dict[str, Any]: - return { - "type": "limit", - "limit": self.limit, - "offset": self.offset - } - + return {"type": "limit", "limit": self.limit, "offset": self.offset} + def __str__(self) -> str: if self.offset: return f"LIMIT {self.limit} OFFSET {self.offset}" @@ -339,6 +335,7 @@ def __str__(self) -> str: @dataclass class Query: """Complete ParaQL query representation.""" + query_type: QueryType select: Optional[SelectClause] = None from_clause: Optional[FromClause] = None @@ -348,7 +345,7 @@ class Query: having: Optional[HavingClause] = None order_by: Optional[OrderByClause] = None limit: Optional[LimitClause] = None - + def to_dict(self) -> Dict[str, Any]: return { "query_type": self.query_type.value, @@ -359,69 +356,86 @@ def to_dict(self) -> Dict[str, Any]: "group_by": self.group_by.to_dict() if self.group_by else None, "having": self.having.to_dict() if self.having else None, "order_by": self.order_by.to_dict() if self.order_by else None, - "limit": self.limit.to_dict() if self.limit else None + "limit": self.limit.to_dict() if self.limit else None, } - + def __str__(self) -> str: parts = [] - + if self.select: parts.append(str(self.select)) - + if self.from_clause: parts.append(str(self.from_clause)) - + for join in self.joins: parts.append(str(join)) - + if self.where: parts.append(str(self.where)) - + if self.group_by: parts.append(str(self.group_by)) - + if self.having: parts.append(str(self.having)) - + if self.order_by: parts.append(str(self.order_by)) - + if self.limit: parts.append(str(self.limit)) - + return " ".join(parts) class QueryParser: """Parser for ParaQL queries.""" - + def __init__(self): self.tokens: List[str] = [] self.current: int = 0 - + def parse(self, query_string: str) -> Query: """Parse a ParaQL query string into an AST.""" self.tokens = self._tokenize(query_string) self.current = 0 return self._parse_query() - + def _tokenize(self, query_string: str) -> List[str]: """Tokenize the query string.""" # Remove extra whitespace and split on keywords - query = re.sub(r'\s+', ' ', query_string.strip()) - + query = re.sub(r"\s+", " ", query_string.strip()) + # Split on SQL keywords while preserving them keywords = [ - 'SELECT', 'FROM', 'WHERE', 'JOIN', 'ON', 'GROUP BY', 'HAVING', - 'ORDER BY', 'LIMIT', 'OFFSET', 'DISTINCT', 'AND', 'OR', 'NOT', - 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS', 'AS' + "SELECT", + "FROM", + "WHERE", + "JOIN", + "ON", + "GROUP BY", + "HAVING", + "ORDER BY", + "LIMIT", + "OFFSET", + "DISTINCT", + "AND", + "OR", + "NOT", + "INNER", + "LEFT", + "RIGHT", + "FULL", + "CROSS", + "AS", ] - + tokens = [] current = query - + for keyword in sorted(keywords, key=len, reverse=True): - pattern = rf'\b{re.escape(keyword)}\b' + pattern = rf"\b{re.escape(keyword)}\b" parts = re.split(pattern, current, flags=re.IGNORECASE) if len(parts) > 1: for i, part in enumerate(parts): @@ -432,15 +446,14 @@ def _tokenize(self, query_string: str) -> List[str]: break else: tokens = self._split_on_operators(current) - + return [token for token in tokens if token.strip()] - + def _split_on_operators(self, text: str) -> List[str]: """Split text on operators.""" - operators = ['=', '!=', '<', '<=', '>', '>=', '(', ')', ',', '.'] - result = [] + operators = ["=", "!=", "<", "<=", ">", ">=", "(", ")", ",", "."] current = text - + for op in operators: parts = current.split(op) if len(parts) > 1: @@ -450,68 +463,68 @@ def _split_on_operators(self, text: str) -> List[str]: new_parts.append(part.strip()) if i < len(parts) - 1: new_parts.append(op) - current = ' '.join(new_parts) - + current = " ".join(new_parts) + return [str(item) for item in current.split()] - + def _parse_query(self) -> Query: """Parse the complete query.""" if self.current >= len(self.tokens): raise ValueError("Unexpected end of query") - + # Determine query type query_type = self._parse_query_type() - + # Parse SELECT clause select_clause = None if query_type == QueryType.SELECT: select_clause = self._parse_select() - + # Parse FROM clause from_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'FROM': + if peek_token and peek_token.upper() == "FROM": from_clause = self._parse_from() - + # Parse JOIN clauses joins = [] while True: peek_token = self._peek() - if peek_token and peek_token.upper() in ['INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS']: + if peek_token and peek_token.upper() in ["INNER", "LEFT", "RIGHT", "FULL", "CROSS"]: joins.append(self._parse_join()) else: break - + # Parse WHERE clause where_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'WHERE': + if peek_token and peek_token.upper() == "WHERE": where_clause = self._parse_where() - + # Parse GROUP BY clause group_by_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'GROUP': + if peek_token and peek_token.upper() == "GROUP": group_by_clause = self._parse_group_by() - + # Parse HAVING clause having_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'HAVING': + if peek_token and peek_token.upper() == "HAVING": having_clause = self._parse_having() - + # Parse ORDER BY clause order_by_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'ORDER': + if peek_token and peek_token.upper() == "ORDER": order_by_clause = self._parse_order_by() - + # Parse LIMIT clause limit_clause = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'LIMIT': + if peek_token and peek_token.upper() == "LIMIT": limit_clause = self._parse_limit() - + return Query( query_type=query_type, select=select_clause, @@ -521,85 +534,95 @@ def _parse_query(self) -> Query: group_by=group_by_clause, having=having_clause, order_by=order_by_clause, - limit=limit_clause + limit=limit_clause, ) - + def _parse_query_type(self) -> QueryType: """Parse the query type.""" if self.current >= len(self.tokens): raise ValueError("Expected query type but found end of input") - + token = self.tokens[self.current].upper() self.current += 1 - - if token == 'SELECT': + + if token == "SELECT": return QueryType.SELECT - elif token == 'INSERT': + elif token == "INSERT": return QueryType.INSERT - elif token == 'UPDATE': + elif token == "UPDATE": return QueryType.UPDATE - elif token == 'DELETE': + elif token == "DELETE": return QueryType.DELETE - elif token == 'CREATE': + elif token == "CREATE": return QueryType.CREATE - elif token == 'DROP': + elif token == "DROP": return QueryType.DROP else: raise ValueError(f"Unknown query type: {token}") - + def _parse_select(self) -> SelectClause: """Parse SELECT clause.""" if self.current >= len(self.tokens): raise ValueError("Expected SELECT but found end of input") - + distinct = False peek_token = self._peek() - if peek_token and peek_token.upper() == 'DISTINCT': + if peek_token and peek_token.upper() == "DISTINCT": distinct = True self.current += 1 - + expressions = [] while self.current < len(self.tokens): peek_token = self._peek() - if not peek_token or peek_token.upper() == 'FROM': + if not peek_token or peek_token.upper() == "FROM": break - - if expressions and peek_token == ',': + + if expressions and peek_token == ",": self.current += 1 # Skip comma - + expr = self._parse_expression() expressions.append(expr) - + if not expressions: raise ValueError("SELECT clause must have at least one expression") - + return SelectClause(expressions=expressions, distinct=distinct) - + def _parse_from(self) -> FromClause: """Parse FROM clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'FROM': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "FROM": raise ValueError("Expected FROM keyword") - + self.current += 1 # Skip FROM - + tables = [] while self.current < len(self.tokens): peek_token = self._peek() - if not peek_token or peek_token.upper() in ['WHERE', 'GROUP', 'ORDER', 'LIMIT', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'CROSS']: + if not peek_token or peek_token.upper() in [ + "WHERE", + "GROUP", + "ORDER", + "LIMIT", + "INNER", + "LEFT", + "RIGHT", + "FULL", + "CROSS", + ]: break - - if tables and peek_token == ',': + + if tables and peek_token == ",": self.current += 1 # Skip comma - + table_name = self._peek() if not table_name: raise ValueError("Expected table name") - + self.current += 1 alias = None - + peek_token = self._peek() - if peek_token and peek_token.upper() == 'AS': + if peek_token and peek_token.upper() == "AS": self.current += 1 # Skip AS peek_token = self._peek() if peek_token: @@ -607,59 +630,59 @@ def _parse_from(self) -> FromClause: self.current += 1 else: raise ValueError("Expected alias after AS") - + tables.append({"name": table_name, "alias": alias}) - + if not tables: raise ValueError("FROM clause must have at least one table") - + return FromClause(tables=tables) - + def _parse_join(self) -> JoinClause: """Parse JOIN clause.""" if self.current >= len(self.tokens): raise ValueError("Expected JOIN but found end of input") - + join_type = JoinType.INNER token = self._peek() if not token: raise ValueError("Expected join type") - + token_upper = token.upper() - if token_upper == 'INNER': + if token_upper == "INNER": join_type = JoinType.INNER self.current += 1 - elif token_upper == 'LEFT': + elif token_upper == "LEFT": join_type = JoinType.LEFT self.current += 1 - elif token_upper == 'RIGHT': + elif token_upper == "RIGHT": join_type = JoinType.RIGHT self.current += 1 - elif token_upper == 'FULL': + elif token_upper == "FULL": join_type = JoinType.FULL self.current += 1 - elif token_upper == 'CROSS': + elif token_upper == "CROSS": join_type = JoinType.CROSS self.current += 1 - + peek_token = self._peek() - if not peek_token or peek_token.upper() != 'JOIN': + if not peek_token or peek_token.upper() != "JOIN": raise ValueError("Expected JOIN keyword") - + self.current += 1 # Skip JOIN - + if self.current >= len(self.tokens): raise ValueError("Expected table name after JOIN") - + table_name = self._peek() if not table_name: raise ValueError("Expected table name after JOIN") - + self.current += 1 - + alias = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'AS': + if peek_token and peek_token.upper() == "AS": self.current += 1 # Skip AS peek_token = self._peek() if peek_token: @@ -667,285 +690,285 @@ def _parse_join(self) -> JoinClause: self.current += 1 else: raise ValueError("Expected alias after AS") - + condition = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'ON': + if peek_token and peek_token.upper() == "ON": self.current += 1 # Skip ON condition = self._parse_expression() - + return JoinClause(join_type=join_type, table=table_name, alias=alias, condition=condition) - + def _parse_where(self) -> WhereClause: """Parse WHERE clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'WHERE': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "WHERE": raise ValueError("Expected WHERE keyword") - + self.current += 1 # Skip WHERE - + if self.current >= len(self.tokens): raise ValueError("Expected condition after WHERE") - + condition = self._parse_expression() return WhereClause(condition=condition) - + def _parse_group_by(self) -> GroupByClause: """Parse GROUP BY clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'GROUP': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "GROUP": raise ValueError("Expected GROUP keyword") - + self.current += 1 # Skip GROUP - - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'BY': + + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "BY": raise ValueError("Expected BY keyword") - + self.current += 1 # Skip BY - + expressions = [] while self.current < len(self.tokens): peek_token = self._peek() - if not peek_token or peek_token.upper() in ['HAVING', 'ORDER', 'LIMIT']: + if not peek_token or peek_token.upper() in ["HAVING", "ORDER", "LIMIT"]: break - - if expressions and peek_token == ',': + + if expressions and peek_token == ",": self.current += 1 # Skip comma - + expr = self._parse_expression() expressions.append(expr) - + if not expressions: raise ValueError("GROUP BY clause must have at least one expression") - + return GroupByClause(expressions=expressions) - + def _parse_having(self) -> HavingClause: """Parse HAVING clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'HAVING': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "HAVING": raise ValueError("Expected HAVING keyword") - + self.current += 1 # Skip HAVING - + if self.current >= len(self.tokens): raise ValueError("Expected condition after HAVING") - + condition = self._parse_expression() return HavingClause(condition=condition) - + def _parse_order_by(self) -> OrderByClause: """Parse ORDER BY clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'ORDER': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "ORDER": raise ValueError("Expected ORDER keyword") - + self.current += 1 # Skip ORDER - - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'BY': + + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "BY": raise ValueError("Expected BY keyword") - + self.current += 1 # Skip BY - + expressions = [] while self.current < len(self.tokens): peek_token = self._peek() - if not peek_token or peek_token.upper() == 'LIMIT': + if not peek_token or peek_token.upper() == "LIMIT": break - - if expressions and peek_token == ',': + + if expressions and peek_token == ",": self.current += 1 # Skip comma - + expr = self._parse_expression() direction = "ASC" - + peek_token = self._peek() - if peek_token and peek_token.upper() in ['ASC', 'DESC']: + if peek_token and peek_token.upper() in ["ASC", "DESC"]: direction = peek_token.upper() self.current += 1 - + expressions.append({"expression": expr, "direction": direction}) - + if not expressions: raise ValueError("ORDER BY clause must have at least one expression") - + return OrderByClause(expressions=expressions) - + def _parse_limit(self) -> LimitClause: """Parse LIMIT clause.""" - if self.current >= len(self.tokens) or self.tokens[self.current].upper() != 'LIMIT': + if self.current >= len(self.tokens) or self.tokens[self.current].upper() != "LIMIT": raise ValueError("Expected LIMIT keyword") - + self.current += 1 # Skip LIMIT - + if self.current >= len(self.tokens): raise ValueError("Expected number after LIMIT") - + limit_token = self._peek() if not limit_token: raise ValueError("Expected number after LIMIT") - + try: limit = int(limit_token) self.current += 1 except ValueError: raise ValueError("LIMIT must be a number") - + offset = None peek_token = self._peek() - if peek_token and peek_token.upper() == 'OFFSET': + if peek_token and peek_token.upper() == "OFFSET": self.current += 1 # Skip OFFSET - + if self.current >= len(self.tokens): raise ValueError("Expected number after OFFSET") - + offset_token = self._peek() if not offset_token: raise ValueError("Expected number after OFFSET") - + try: offset = int(offset_token) self.current += 1 except ValueError: raise ValueError("OFFSET must be a number") - + return LimitClause(limit=limit, offset=offset) - + def _parse_expression(self) -> Expression: """Parse an expression.""" if self.current >= len(self.tokens): raise ValueError("Expected expression but found end of input") - + token = self._peek() if not token: raise ValueError("Expected expression but found end of input") - + # Check for literal values if token.startswith("'") and token.endswith("'"): self.current += 1 return Literal(value=token[1:-1]) - - if token.isdigit() or (token.startswith('-') and token[1:].isdigit()): + + if token.isdigit() or (token.startswith("-") and token[1:].isdigit()): self.current += 1 return Literal(value=int(token)) - - if token.replace('.', '').isdigit(): + + if token.replace(".", "").isdigit(): self.current += 1 return Literal(value=float(token)) - + # Check for function calls peek_token = self._peek(1) - if peek_token and peek_token == '(': + if peek_token and peek_token == "(": return self._parse_function_call() - + # Check for aggregation functions token_upper = token.upper() if token_upper in [agg.value for agg in AggregationType]: return self._parse_aggregation() - + # Check for operators - if token in ['NOT', '!']: + if token in ["NOT", "!"]: return self._parse_unary_expression() - + # Default to column reference return self._parse_column_reference() - + def _parse_function_call(self) -> FunctionCall: """Parse a function call.""" name = self._peek() if not name: raise ValueError("Expected function name") - + self.current += 1 # Skip function name - + peek_token = self._peek() - if peek_token != '(': + if peek_token != "(": raise ValueError("Expected '(' after function name") - + self.current += 1 # Skip '(' - + arguments = [] while self.current < len(self.tokens): peek_token = self._peek() - if not peek_token or peek_token == ')': + if not peek_token or peek_token == ")": break - - if arguments and peek_token == ',': + + if arguments and peek_token == ",": self.current += 1 # Skip comma - + arg = self._parse_expression() arguments.append(arg) - + peek_token = self._peek() - if peek_token != ')': + if peek_token != ")": raise ValueError("Expected ')' to close function call") - + self.current += 1 # Skip ')' - + return FunctionCall(name=name, arguments=arguments) - + def _parse_aggregation(self) -> AggregationExpression: """Parse an aggregation function.""" func_name = self._peek() if not func_name: raise ValueError("Expected aggregation function name") - + func_name_upper = func_name.upper() self.current += 1 - + distinct = False peek_token = self._peek() - if peek_token and peek_token.upper() == 'DISTINCT': + if peek_token and peek_token.upper() == "DISTINCT": distinct = True self.current += 1 - + peek_token = self._peek() - if peek_token != '(': + if peek_token != "(": raise ValueError("Expected '(' after aggregation function") - + self.current += 1 # Skip '(' - + argument = self._parse_expression() - + peek_token = self._peek() - if peek_token != ')': + if peek_token != ")": raise ValueError("Expected ')' to close aggregation function") - + self.current += 1 # Skip ')' - + try: func = AggregationType(func_name_upper) except ValueError: raise ValueError(f"Unknown aggregation function: {func_name_upper}") - + return AggregationExpression(function=func, argument=argument, distinct=distinct) - + def _parse_unary_expression(self) -> UnaryExpression: """Parse a unary expression.""" operator = self._peek() if not operator: raise ValueError("Expected unary operator") - + self.current += 1 - - if operator.upper() == 'NOT': + + if operator.upper() == "NOT": op = OperatorType.NOT else: raise ValueError(f"Unknown unary operator: {operator}") - + operand = self._parse_expression() return UnaryExpression(operator=op, operand=operand) - + def _parse_column_reference(self) -> ColumnReference: """Parse a column reference.""" if self.current >= len(self.tokens): raise ValueError("Expected column name but found end of input") - + name = self._peek() if not name: raise ValueError("Expected column name but found end of input") - + self.current += 1 - + table = None peek_token = self._peek() - if peek_token == '.': + if peek_token == ".": self.current += 1 # Skip '.' if self.current >= len(self.tokens): raise ValueError("Expected column name after '.'") @@ -954,9 +977,9 @@ def _parse_column_reference(self) -> ColumnReference: if not name: raise ValueError("Expected column name after '.'") self.current += 1 - + return ColumnReference(name=name, table=table) - + def _peek(self, offset: int = 0) -> Optional[str]: """Peek at the next token without consuming it.""" if self.current + offset >= len(self.tokens): @@ -996,7 +1019,9 @@ def function(name: str, *arguments: Expression) -> FunctionCall: return FunctionCall(name, list(arguments)) -def aggregate(func: AggregationType, argument: Expression, distinct: bool = False) -> AggregationExpression: +def aggregate( + func: AggregationType, argument: Expression, distinct: bool = False +) -> AggregationExpression: """Create an aggregation expression.""" return AggregationExpression(func, argument, distinct) diff --git a/src/pyocc/base.py b/src/pyocc/base.py index 4c46f58..c530a8c 100644 --- a/src/pyocc/base.py +++ b/src/pyocc/base.py @@ -7,39 +7,38 @@ across different shape types. """ -import numpy as np import logging -from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple, Union, Any -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.TopExp import topexp -from OCC.Core.TopTools import TopTools_IndexedMapOfShape +from typing import TYPE_CHECKING, Iterator, List, Optional, Tuple + +import numpy as np +from OCC.Core.Bnd import Bnd_Box +from OCC.Core.BRep import BRep_Tool +from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.BRepGProp import brepgprop +from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh +from OCC.Core.gp import gp_Pnt, gp_Vec +from OCC.Core.GProp import GProp_GProps from OCC.Core.TopAbs import ( - TopAbs_VERTEX, + TopAbs_COMPOUND, + TopAbs_COMPSOLID, TopAbs_EDGE, TopAbs_FACE, - TopAbs_WIRE, TopAbs_SHELL, TopAbs_SOLID, - TopAbs_COMPOUND, - TopAbs_COMPSOLID, + TopAbs_VERTEX, + TopAbs_WIRE, ) -from OCC.Core.BRepGProp import brepgprop -from OCC.Core.GProp import GProp_GProps -from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh -from OCC.Core.Bnd import Bnd_Box -from OCC.Core.BRepBndLib import brepbndlib +from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopoDS import TopoDS_Shape -from OCC.Core.gp import gp_Pnt, gp_Vec -from OCC.Core.TopLoc import TopLoc_Location -from OCC.Core.BRep import BRep_Tool +from OCC.Core.TopTools import TopTools_IndexedMapOfShape if TYPE_CHECKING: - from .vertex import Vertex from .edge import Edge from .face import Face - from .wire import Wire from .shell import Shell from .solid import Solid + from .vertex import Vertex + from .wire import Wire class TopologyUtils: @@ -256,7 +255,7 @@ def ordered_vertices(self): explorer = BRepTools_WireExplorer(self.wire) while explorer.More(): - edge = explorer.Current() + explorer.Current() # Get the current vertex for this edge vertex = explorer.CurrentVertex() vertices.append(vertex) @@ -297,9 +296,10 @@ def vertices(self) -> Iterator["Vertex"]: Iterator of Vertex objects """ # Import inside method to avoid circular imports - from .vertex import Vertex from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .vertex import Vertex + # Use TopTools_IndexedMapOfShape for deduplication vertex_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_VERTEX, vertex_map) # type: ignore @@ -319,7 +319,6 @@ def vertices_from_edge(self, edge) -> List["Vertex"]: List of Vertex objects """ # Import inside method to avoid circular imports - from .vertex import Vertex # Use edge's topological methods to get boundary vertices return [edge.start_vertex(), edge.end_vertex()] @@ -367,8 +366,8 @@ def split_all_closed_edges(self): A new shape with closed edges split into multiple segments """ try: - from OCC.Core.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Copy + from OCC.Core.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle # Create a copy of the shape to work on copy_maker = BRepBuilderAPI_Copy(self.topods_shape()) @@ -420,9 +419,10 @@ def edges(self) -> Iterator["Edge"]: Iterator of Edge objects """ # Import inside method to avoid circular imports - from .edge import Edge from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .edge import Edge + # Use TopTools_IndexedMapOfShape for deduplication edge_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_EDGE, edge_map) # type: ignore @@ -442,7 +442,6 @@ def vertices_from_edge(self, edge) -> List["Vertex"]: List of Vertex objects """ # Import inside method to avoid circular imports - from .vertex import Vertex # Use edge's topological methods to get boundary vertices return [edge.start_vertex(), edge.end_vertex()] @@ -488,8 +487,8 @@ def split_all_closed_edges(self): A new shape with closed edges split into multiple segments """ try: - from OCC.Core.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Copy + from OCC.Core.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle # Create a copy of the shape to work on copy_maker = BRepBuilderAPI_Copy(self.topods_shape()) @@ -541,9 +540,10 @@ def faces(self) -> Iterator["Face"]: Iterator of Face objects """ # Import inside method to avoid circular imports - from .face import Face from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .face import Face + # Use TopTools_IndexedMapOfShape for deduplication face_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_FACE, face_map) # type: ignore @@ -562,14 +562,9 @@ def vertices_from_face(self, face) -> List["Vertex"]: Returns: List of Vertex objects """ - # Import inside method to avoid circular imports - from .vertex import Vertex - - # Use face's topological methods to get boundary vertices - vertices = [] - for edge in face.edges(): - vertices.extend(edge.vertices()) - return vertices + # Return the unique vertices that bound the face. face.vertices() deduplicates + # via an indexed map, so shared corners are not counted once per incident edge. + return list(face.vertices()) def edges_from_face(self, face) -> List["Edge"]: """ @@ -594,14 +589,9 @@ def wires_from_face(self, face) -> List["Wire"]: Returns: List of Wire objects """ - # Import inside method to avoid circular imports - from .wire import Wire - - # Use face's topological methods to get boundary wires - wires = [] - for wire in face.wires(): - wires.append(Wire(wire)) - return wires + # face.wires() already yields deduplicated pyocc Wire objects, so return them + # directly (re-wrapping a Wire would fail its TopoDS_Wire type check). + return list(face.wires()) def find_closest_face_slow(self, datum) -> Optional[Tuple]: """ @@ -642,8 +632,10 @@ def split_all_closed_faces(self): A new shape with closed faces split into multiple segments """ try: - from OCC.Core.ShapeUpgrade import ShapeUpgrade_ShapeDivideClosedFaces from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Copy + from OCC.Core.ShapeUpgrade import ( + ShapeUpgrade_ShapeDivideClosedFaces, + ) # Create a copy of the shape to work on copy_maker = BRepBuilderAPI_Copy(self.topods_shape()) @@ -694,9 +686,10 @@ def wires(self) -> Iterator["Wire"]: Iterator of Wire objects """ # Import inside method to avoid circular imports - from .wire import Wire from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .wire import Wire + # Use TopTools_IndexedMapOfShape for deduplication wire_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_WIRE, wire_map) # type: ignore @@ -735,9 +728,10 @@ def shells(self) -> Iterator["Shell"]: Iterator of Shell objects """ # Import inside method to avoid circular imports - from .shell import Shell from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .shell import Shell + # Use TopTools_IndexedMapOfShape for deduplication shell_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_SHELL, shell_map) # type: ignore @@ -776,9 +770,10 @@ def solids(self) -> Iterator["Solid"]: Iterator of Solid objects """ # Import inside method to avoid circular imports - from .solid import Solid from OCC.Core.TopTools import TopTools_IndexedMapOfShape + from .solid import Solid + # Use TopTools_IndexedMapOfShape for deduplication solid_map = TopTools_IndexedMapOfShape() topexp.MapShapes(self.topods_shape(), TopAbs_SOLID, solid_map) # type: ignore @@ -867,7 +862,6 @@ def edge_continuity(self, edge) -> dict: Dictionary with continuity information """ # Import inside method to avoid circular imports - from .face import Face # Find faces that contain this edge containing_faces = self.faces_from_edge(edge) @@ -884,7 +878,7 @@ def edge_continuity(self, edge) -> dict: # Check for sharp edges (discontinuity in surface normals) if len(containing_faces) == 2: - face1, face2 = containing_faces[0], containing_faces[1] + _face1, _face2 = containing_faces[0], containing_faces[1] # Compare surface normals at the edge # This is a simplified check - in practice you'd need more sophisticated analysis continuity_info["sharp_edge"] = True # Simplified @@ -910,20 +904,23 @@ def edges_from_vertex(self, vertex) -> List["Edge"]: # Import inside method to avoid circular imports from .edge import Edge - # Use OpenCASCADE's topology explorer to find edges containing the vertex - explorer = TopExp_Explorer(self.topods_shape(), TopAbs_EDGE) # type: ignore + # A raw TopExp_Explorer over a solid visits each edge once per adjacent face, + # so deduplicate the shape's edges via an indexed map before testing them. + edge_map = TopTools_IndexedMapOfShape() + topexp.MapShapes(self.topods_shape(), TopAbs_EDGE, edge_map) + + target_vertex = vertex.topods_vertex() containing_edges = [] - while explorer.More(): - edge_shape = explorer.Current() + for i in range(1, edge_map.Size() + 1): + edge_shape = edge_map.FindKey(i) # Check if this edge contains the vertex edge_vertices = TopExp_Explorer(edge_shape, TopAbs_VERTEX) # type: ignore while edge_vertices.More(): - if edge_vertices.Current().IsSame(vertex.topods_vertex()): + if edge_vertices.Current().IsSame(target_vertex): containing_edges.append(Edge(edge_shape)) break edge_vertices.Next() - explorer.Next() return containing_edges @@ -1245,7 +1242,7 @@ def get_triangles( """ from OCC.Core.TopAbs import TopAbs_FACE from OCC.Core.TopExp import TopExp_Explorer - from OCC.Core.Poly import Poly_Triangulation + from OCC.Core.TopoDS import topods # First triangulate all faces self.triangulate_all_faces(linear_deflection, angular_deflection) @@ -1260,42 +1257,53 @@ def get_triangles( vertex_offset = 0 while explorer.More(): - face_shape = explorer.Current() + face_shape = topods.Face(explorer.Current()) - # Get triangulation for this face - triangulation = Poly_Triangulation() - if triangulation.IsNull(): + # Get the face's stored triangulation (OCC 7.6+ API). BRep_Tool.Triangulation + # returns None when the face has no mesh rather than a null handle. + location = face_shape.Location() + triangulation = BRep_Tool.Triangulation(face_shape, location) + if triangulation is None: explorer.Next() continue - # Get vertices and triangles from triangulation - vertices = triangulation.Nodes() - triangles = triangulation.Triangles() - - # Add vertices to global list - for i in range(1, vertices.Length() + 1): - vertex = vertices.Value(i) - all_vertices.append([vertex.X(), vertex.Y(), vertex.Z()]) - - # Add triangles to global list (adjust indices for global vertex list) - for i in range(1, triangles.Length() + 1): - triangle = triangles.Value(i) - # Convert to 0-based indexing and adjust for global vertex list - global_triangle = [ - triangle.Value(1) - 1 + vertex_offset, - triangle.Value(2) - 1 + vertex_offset, - triangle.Value(3) - 1 + vertex_offset, - ] - all_triangles.append(global_triangle) - - # Add normals if requested - if return_normals: - normals = triangulation.Normals() - for i in range(1, normals.Length() + 1): - normal = normals.Value(i) - all_normals.append([normal.X(), normal.Y(), normal.Z()]) - - vertex_offset += vertices.Length() + transform = location.Transformation() + is_identity = location.IsIdentity() + num_nodes = triangulation.NbNodes() + + # Add this face's nodes to the global vertex list, applying the face location + face_vertices = [] + for i in range(1, num_nodes + 1): + node = triangulation.Node(i) + if not is_identity: + node = node.Transformed(transform) + coords = [node.X(), node.Y(), node.Z()] + face_vertices.append(coords) + all_vertices.append(coords) + + # Add this face's triangles (0-based indices offset into the global vertex list) + for i in range(1, triangulation.NbTriangles() + 1): + n1, n2, n3 = triangulation.Triangle(i).Get() + all_triangles.append( + [ + n1 - 1 + vertex_offset, + n2 - 1 + vertex_offset, + n3 - 1 + vertex_offset, + ] + ) + + # One unit outward normal per triangle, from its corner nodes + if return_normals: + v1 = np.array(face_vertices[n1 - 1]) + v2 = np.array(face_vertices[n2 - 1]) + v3 = np.array(face_vertices[n3 - 1]) + normal = np.cross(v2 - v1, v3 - v1) + norm = np.linalg.norm(normal) + if norm > 0: + normal = normal / norm + all_normals.append(normal.tolist()) + + vertex_offset += num_nodes explorer.Next() # Convert to numpy arrays diff --git a/src/pyocc/batch/__init__.py b/src/pyocc/batch/__init__.py index a0ec3c1..78cf798 100644 --- a/src/pyocc/batch/__init__.py +++ b/src/pyocc/batch/__init__.py @@ -6,21 +6,21 @@ """ from .render_multiview import ( + BatchProcessor, MultiViewRenderer, RenderSettings, ViewAngle, - BatchProcessor, - create_documentation_images, create_comparison_grid, - render_animation_frames + create_documentation_images, + render_animation_frames, ) __all__ = [ - 'MultiViewRenderer', - 'RenderSettings', - 'ViewAngle', - 'BatchProcessor', - 'create_documentation_images', - 'create_comparison_grid', - 'render_animation_frames' + "MultiViewRenderer", + "RenderSettings", + "ViewAngle", + "BatchProcessor", + "create_documentation_images", + "create_comparison_grid", + "render_animation_frames", ] diff --git a/src/pyocc/batch/render_multiview.py b/src/pyocc/batch/render_multiview.py index 0469e3d..d78635e 100644 --- a/src/pyocc/batch/render_multiview.py +++ b/src/pyocc/batch/render_multiview.py @@ -4,19 +4,23 @@ This module provides tools for rendering multiple views of shapes simultaneously, batch processing of shape collections, and automated image generation workflows. """ -import os -import numpy as np -from pathlib import Path -from typing import List, Dict, Optional, Union, Tuple, Any, TYPE_CHECKING -from enum import Enum -from concurrent.futures import ThreadPoolExecutor, as_completed -import logging -from PIL import Image, ImageDraw, ImageFont # Add PIL imports at the top level + import json +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union -from OCC.Core.gp import gp_Dir, gp_Pnt +import numpy as np +from OCC.Core.gp import gp_Dir from OCC.Core.V3d import V3d_TypeOfOrientation +from PIL import ( + Image, # Add PIL imports at the top level + ImageDraw, + ImageFont, +) if TYPE_CHECKING: from pyocc.shape import Shape @@ -25,6 +29,7 @@ class ViewAngle(Enum): """Standard view angles for multi-view rendering.""" + FRONT = "front" BACK = "back" LEFT = "left" @@ -43,13 +48,15 @@ class RenderSettings: image resolution, lighting, materials, and view parameters. """ - def __init__(self, - width: int = 1920, - height: int = 1080, - background_color: Tuple[float, float, float] = (0.95, 0.95, 0.95), - use_antialiasing: bool = True, - use_shadows: bool = True, - image_format: str = "PNG"): + def __init__( + self, + width: int = 1920, + height: int = 1080, + background_color: Tuple[float, float, float] = (0.95, 0.95, 0.95), + use_antialiasing: bool = True, + use_shadows: bool = True, + image_format: str = "PNG", + ): """ Initialize render settings. @@ -72,15 +79,11 @@ def __init__(self, self.ambient_light = 0.3 self.directional_lights = [ {"direction": (-1, -1, -1), "intensity": 0.8, "color": (1.0, 1.0, 1.0)}, - {"direction": (1, 1, 1), "intensity": 0.4, "color": (1.0, 1.0, 1.0)} + {"direction": (1, 1, 1), "intensity": 0.4, "color": (1.0, 1.0, 1.0)}, ] # Material settings - self.default_material = { - "color": (0.7, 0.7, 0.9), - "transparency": 0.0, - "shininess": 0.5 - } + self.default_material = {"color": (0.7, 0.7, 0.9), "transparency": 0.0, "shininess": 0.5} class MultiViewRenderer: @@ -101,13 +104,13 @@ def __init__(self, settings: Optional[RenderSettings] = None): self.settings = settings or RenderSettings() self._renderer = None - def _get_renderer(self) -> 'OffscreenRenderer': + def _get_renderer(self) -> "OffscreenRenderer": """Get or create the offscreen renderer.""" if self._renderer is None: from pyocc.viewer import OffscreenRenderer # Lazy import + self._renderer = OffscreenRenderer( - self.settings.width, - self.settings.height + width=self.settings.width, height=self.settings.height ) self._setup_renderer() return self._renderer @@ -137,9 +140,13 @@ def _setup_renderer(self): color = light["color"] renderer.add_directional_light(direction, color, intensity) - def render_view(self, shape: 'Shape', view_angle: ViewAngle, - output_path: Union[str, Path], - custom_direction: Optional[Tuple[float, float, float]] = None) -> bool: + def render_view( + self, + shape: "Shape", + view_angle: ViewAngle, + output_path: Union[str, Path], + custom_direction: Optional[Tuple[float, float, float]] = None, + ) -> bool: """ Render a single view of a shape. @@ -158,9 +165,7 @@ def render_view(self, shape: 'Shape', view_angle: ViewAngle, # Display the shape material = self.settings.default_material - renderer.display(shape, - color=material["color"], - transparency=material["transparency"]) + renderer.display(shape, color=material["color"], transparency=material["transparency"]) # Set view direction self._set_view_direction(renderer, view_angle, custom_direction) @@ -175,9 +180,13 @@ def render_view(self, shape: 'Shape', view_angle: ViewAngle, logging.error(f"Error rendering view {view_angle}: {e}") return False - def render_multi_view(self, shape: 'Shape', output_dir: Union[str, Path], - views: Optional[List[ViewAngle]] = None, - filename_prefix: str = "view") -> Dict[ViewAngle, bool]: + def render_multi_view( + self, + shape: "Shape", + output_dir: Union[str, Path], + views: Optional[List[ViewAngle]] = None, + filename_prefix: str = "view", + ) -> Dict[ViewAngle, bool]: """ Render multiple standard views of a shape. @@ -191,8 +200,14 @@ def render_multi_view(self, shape: 'Shape', output_dir: Union[str, Path], Dictionary mapping view angles to success status """ if views is None: - views = [ViewAngle.FRONT, ViewAngle.BACK, ViewAngle.LEFT, - ViewAngle.RIGHT, ViewAngle.TOP, ViewAngle.BOTTOM] + views = [ + ViewAngle.FRONT, + ViewAngle.BACK, + ViewAngle.LEFT, + ViewAngle.RIGHT, + ViewAngle.TOP, + ViewAngle.BOTTOM, + ] output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) @@ -213,10 +228,13 @@ def render_multi_view(self, shape: 'Shape', output_dir: Union[str, Path], return results - def render_comparison_grid(self, shapes: List['Shape'], - output_path: Union[str, Path], - views: Optional[List[ViewAngle]] = None, - shape_names: Optional[List[str]] = None) -> bool: + def render_comparison_grid( + self, + shapes: List["Shape"], + output_path: Union[str, Path], + views: Optional[List[ViewAngle]] = None, + shape_names: Optional[List[str]] = None, + ) -> bool: """ Render a comparison grid showing multiple shapes from multiple views. @@ -265,13 +283,17 @@ def render_comparison_grid(self, shapes: List['Shape'], padding = 20 # Pixels between images label_height = 30 # Height for labels grid_width = (img_width * len(views)) + (padding * (len(views) + 1)) - grid_height = (img_height * len(shapes)) + (padding * (len(shapes) + 1)) + (label_height * 2) + grid_height = ( + (img_height * len(shapes)) + (padding * (len(shapes) + 1)) + (label_height * 2) + ) # Create canvas for the grid - bg_color = (int(self.settings.background_color[0]*255), - int(self.settings.background_color[1]*255), - int(self.settings.background_color[2]*255)) - grid_img = Image.new('RGB', (grid_width, grid_height), color=bg_color) + bg_color = ( + int(self.settings.background_color[0] * 255), + int(self.settings.background_color[1] * 255), + int(self.settings.background_color[2] * 255), + ) + grid_img = Image.new("RGB", (grid_width, grid_height), color=bg_color) draw = ImageDraw.Draw(grid_img) # Try to get a font, fall back to default if not available @@ -290,7 +312,9 @@ def render_comparison_grid(self, shapes: List['Shape'], else: # Older Pillow versions text_width, _ = draw.textsize(label_text, font=font) - label_x = padding + (j * (img_width + padding)) + (img_width // 2) - (text_width // 2) + label_x = ( + padding + (j * (img_width + padding)) + (img_width // 2) - (text_width // 2) + ) draw.text((label_x, padding), label_text, fill=(0, 0, 0), font=font) # Place images and add shape labels @@ -303,7 +327,13 @@ def render_comparison_grid(self, shapes: List['Shape'], else: # Older Pillow versions _, text_height = draw.textsize(shape_name, font=font) - label_y = padding + label_height + (i * (img_height + padding)) + (img_height // 2) - (text_height // 2) + label_y = ( + padding + + label_height + + (i * (img_height + padding)) + + (img_height // 2) + - (text_height // 2) + ) draw.text((padding // 2, label_y), shape_name, fill=(0, 0, 0), font=font) # Place images for this shape @@ -320,7 +350,9 @@ def render_comparison_grid(self, shapes: List['Shape'], # Save the composite image grid_img.save(output_path) - logging.info(f"Created comparison grid with {len(shapes)} shapes and {len(views)} views") + logging.info( + f"Created comparison grid with {len(shapes)} shapes and {len(views)} views" + ) # Cleanup temporary files for temp_path in render_paths.values(): @@ -334,9 +366,13 @@ def render_comparison_grid(self, shapes: List['Shape'], logging.error(f"Error creating comparison grid: {e}") return False - def render_animation_frames(self, shape: 'Shape', output_dir: Union[str, Path], - num_frames: int = 36, - axis: Tuple[float, float, float] = (0, 0, 1)) -> List[Path]: + def render_animation_frames( + self, + shape: "Shape", + output_dir: Union[str, Path], + num_frames: int = 36, + axis: Tuple[float, float, float] = (0, 0, 1), + ) -> List[Path]: """ Render frames for a rotation animation. @@ -361,11 +397,7 @@ def render_animation_frames(self, shape: 'Shape', output_dir: Union[str, Path], # This is a simplified calculation - a full implementation # would handle arbitrary rotation axes properly rad = np.radians(angle) - direction = ( - np.cos(rad), - np.sin(rad), - 0.3 # Slight elevation - ) + direction = (np.cos(rad), np.sin(rad), 0.3) # Slight elevation filename = f"frame_{i:03d}.{self.settings.image_format.lower()}" frame_path = output_dir / filename @@ -379,9 +411,91 @@ def render_animation_frames(self, shape: 'Shape', output_dir: Union[str, Path], return frame_paths - def _set_view_direction(self, renderer: 'OffscreenRenderer', - view_angle: ViewAngle, - custom_direction: Optional[Tuple[float, float, float]] = None): + def render_custom_view(self, shape, camera_position, target_position, up_vector, output_path): + """ + Render a shape from an explicit camera position. + + Args: + shape: Shape to render + camera_position: Camera eye position [x, y, z] + target_position: Point the camera looks at [x, y, z] + up_vector: Camera up vector [x, y, z] + output_path: Path to save the rendered image + + Returns: + True if rendering succeeded, False otherwise + """ + try: + renderer = self._get_renderer() + renderer.clear_scene() + + material = self.settings.default_material + renderer.display(shape, color=material["color"], transparency=material["transparency"]) + + renderer.set_camera(camera_position, target_position, up_vector) + renderer.fit() + return renderer.save_image(str(output_path)) + except Exception as e: + logging.error(f"Error rendering custom view: {e}") + return False + + def render_multiview_grid(self, shape, views, output_path): + """ + Render several views of a single shape composited into one grid image. + + Args: + shape: Shape to render + views: List of ViewAngle values to render + output_path: Path to save the composite grid image + + Returns: + True if successful, False otherwise + """ + try: + output_path = Path(output_path) + temp_dir = output_path.parent / "temp_grid_renders" + temp_dir.mkdir(parents=True, exist_ok=True) + + ext = self.settings.image_format.lower() + rendered = [] + for view in views: + temp_path = temp_dir / f"view_{view.value}.{ext}" + if self.render_view(shape, view, temp_path): + rendered.append(temp_path) + + if not rendered: + logging.error("No successful renders for multiview grid") + return False + + images = [Image.open(p) for p in rendered] + cell_w, cell_h = images[0].size + padding = 10 + cols = len(images) + grid_w = cols * cell_w + padding * (cols + 1) + grid_h = cell_h + padding * 2 + + bg_color = tuple(int(c * 255) for c in self.settings.background_color) + grid_img = Image.new("RGB", (grid_w, grid_h), color=bg_color) + for i, img in enumerate(images): + x = padding + i * (cell_w + padding) + grid_img.paste(img, (x, padding)) + grid_img.save(output_path) + + for p in rendered: + if p.exists(): + p.unlink() + temp_dir.rmdir() + return True + except Exception as e: + logging.error(f"Error rendering multiview grid: {e}") + return False + + def _set_view_direction( + self, + renderer: "OffscreenRenderer", + view_angle: ViewAngle, + custom_direction: Optional[Tuple[float, float, float]] = None, + ): """Set the view direction on the renderer.""" if view_angle == ViewAngle.CUSTOM and custom_direction is not None: # Set custom view direction @@ -412,19 +526,77 @@ class BatchProcessor: parallel execution and progress tracking. """ - def __init__(self, max_workers: int = 4): + def __init__(self, settings: Optional[RenderSettings] = None, max_workers: int = 4): """ Initialize batch processor. Args: + settings: Rendering settings shared by the batch operations max_workers: Maximum number of worker threads for parallel processing """ + self.settings = settings self.max_workers = max_workers - def process_shape_collection(self, shapes: List['Shape'], - output_base_dir: Union[str, Path], - operations: List[str], - settings: Optional[RenderSettings] = None) -> Dict[str, Any]: + def process_shapes(self, shapes, shape_names, output_dir, views): + """ + Render each shape from each view to ``{name}_{view}.{ext}`` in output_dir. + + Args: + shapes: Shapes to render + shape_names: Filename-safe name per shape + output_dir: Directory for the rendered images + views: ViewAngle values to render for every shape + + Returns: + Dict mapping (shape_name, view) -> success bool + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + renderer = MultiViewRenderer(self.settings) + ext = (self.settings.image_format if self.settings else "PNG").lower() + + results = {} + for shape, name in zip(shapes, shape_names): + for view in views: + output_path = output_dir / f"{name}_{view.value}.{ext}" + results[(name, view.value)] = renderer.render_view(shape, view, output_path) + return results + + def process_shapes_parallel(self, shapes, shape_names, output_dir, views): + """ + Parallel variant of :meth:`process_shapes` using a thread pool. + + Each task uses its own MultiViewRenderer so the offscreen renderers do not + share state across threads. + + Returns: + Dict mapping (shape_name, view) -> success bool + """ + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + ext = (self.settings.image_format if self.settings else "PNG").lower() + + def _render_one(shape, name, view): + renderer = MultiViewRenderer(self.settings) + output_path = output_dir / f"{name}_{view.value}.{ext}" + return (name, view.value), renderer.render_view(shape, view, output_path) + + tasks = [(shape, name, view) for shape, name in zip(shapes, shape_names) for view in views] + results = {} + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + futures = [executor.submit(_render_one, s, n, v) for s, n, v in tasks] + for future in as_completed(futures): + key, success = future.result() + results[key] = success + return results + + def process_shape_collection( + self, + shapes: List["Shape"], + output_base_dir: Union[str, Path], + operations: List[str], + settings: Optional[RenderSettings] = None, + ) -> Dict[str, Any]: """ Process a collection of shapes with specified operations. @@ -441,14 +613,14 @@ def process_shape_collection(self, shapes: List['Shape'], output_base_dir.mkdir(parents=True, exist_ok=True) results = { - 'processed_shapes': 0, - 'failed_shapes': 0, - 'operations_completed': {}, - 'output_files': [] + "processed_shapes": 0, + "failed_shapes": 0, + "operations_completed": {}, + "output_files": [], } for operation in operations: - results['operations_completed'][operation] = 0 + results["operations_completed"][operation] = 0 # Process shapes in parallel with ThreadPoolExecutor(max_workers=self.max_workers) as executor: @@ -457,8 +629,7 @@ def process_shape_collection(self, shapes: List['Shape'], for i, shape in enumerate(shapes): shape_dir = output_base_dir / f"shape_{i:03d}" future = executor.submit( - self._process_single_shape, - shape, shape_dir, operations, settings + self._process_single_shape, shape, shape_dir, operations, settings ) futures.append((future, i)) @@ -466,31 +637,32 @@ def process_shape_collection(self, shapes: List['Shape'], for future, shape_index in futures: try: shape_results = future.result() - results['processed_shapes'] += 1 + results["processed_shapes"] += 1 # Merge operation counts - for op, count in shape_results.get('operations', {}).items(): - results['operations_completed'][op] += count + for op, count in shape_results.get("operations", {}).items(): + results["operations_completed"][op] += count # Add output files - results['output_files'].extend(shape_results.get('files', [])) + results["output_files"].extend(shape_results.get("files", [])) except Exception as e: logging.error(f"Error processing shape {shape_index}: {e}") - results['failed_shapes'] += 1 + results["failed_shapes"] += 1 return results - def _process_single_shape(self, shape: 'Shape', output_dir: Path, - operations: List[str], - settings: Optional[RenderSettings]) -> Dict[str, Any]: + def _process_single_shape( + self, + shape: "Shape", + output_dir: Path, + operations: List[str], + settings: Optional[RenderSettings], + ) -> Dict[str, Any]: """Process a single shape with the specified operations.""" output_dir.mkdir(parents=True, exist_ok=True) - results = { - 'operations': {}, - 'files': [] - } + results = {"operations": {}, "files": []} for operation in operations: if operation == "render_views": @@ -498,46 +670,60 @@ def _process_single_shape(self, shape: 'Shape', output_dir: Path, view_results = renderer.render_multi_view(shape, output_dir) success_count = sum(1 for success in view_results.values() if success) - results['operations']['render_views'] = success_count + results["operations"]["render_views"] = success_count # Add successful renders to file list for view, success in view_results.items(): if success: filename = f"view_{view.value}.{settings.image_format.lower() if settings else 'png'}" - results['files'].append(output_dir / filename) + results["files"].append(output_dir / filename) elif operation == "analysis": - # Implement shape analysis + # Compute real geometric properties for the shape. analysis_file = output_dir / "analysis.json" + bbox = shape.box() analysis_data = { - "shape_type": "unknown", # Would analyze actual shape type - "bounding_box": [0, 0, 0, 1, 1, 1], # Would calculate actual bounds - "volume": 0.0, # Would calculate actual volume - "surface_area": 0.0, # Would calculate actual surface area - "analysis_timestamp": str(datetime.now()) + "shape_type": type(shape).__name__, + "bounding_box": [ + float(bbox.min[0]), + float(bbox.min[1]), + float(bbox.min[2]), + float(bbox.max[0]), + float(bbox.max[1]), + float(bbox.max[2]), + ], + "volume": float(shape.volume()) if hasattr(shape, "volume") else 0.0, + "surface_area": float(shape.area()) if hasattr(shape, "area") else 0.0, + "analysis_timestamp": str(datetime.now()), } with open(analysis_file, "w") as f: json.dump(analysis_data, f, indent=2) - results['operations']['analysis'] = 1 - results['files'].append(analysis_file) + results["operations"]["analysis"] = 1 + results["files"].append(analysis_file) elif operation == "export": - # Export in multiple formats + # Export to each supported format using the real io.py writers. + from ..io import save_brep, save_step, save_stl + + exporters = {"step": save_step, "stl": save_stl, "brep": save_brep} export_count = 0 - for fmt in ['step', 'stl', 'brep']: + for fmt, save_fn in exporters.items(): export_file = output_dir / f"shape.{fmt}" - # Would use io.py functions to save in different formats - export_count += 1 - results['files'].append(export_file) + if save_fn(shape, str(export_file)): + export_count += 1 + results["files"].append(export_file) - results['operations']['export'] = export_count + results["operations"]["export"] = export_count return results -def create_documentation_images(shape: 'Shape', output_dir: Union[str, Path], - include_dimensions: bool = True, - include_wireframe: bool = True) -> Dict[str, Path]: +def create_documentation_images( + shape: "Shape", + output_dir: Union[str, Path], + include_dimensions: bool = True, + include_wireframe: bool = True, +) -> Dict[str, Path]: """ Create a complete set of documentation images for a shape. @@ -579,7 +765,7 @@ def create_documentation_images(shape: 'Shape', output_dir: Union[str, Path], # Would need to modify renderer settings for wireframe mode # For now, create a placeholder wireframe image try: - img = Image.new('RGB', (settings.width, settings.height), (255, 255, 255)) + img = Image.new("RGB", (settings.width, settings.height), (255, 255, 255)) draw = ImageDraw.Draw(img) draw.text((50, 50), "Wireframe View", fill=(0, 0, 0)) img.save(wireframe_path) @@ -608,11 +794,13 @@ def create_documentation_images(shape: 'Shape', output_dir: Union[str, Path], return image_paths -def create_comparison_grid(shapes: List['Shape'], - shape_names: Optional[List[str]], - output_path: Union[str, Path], - grid_size: Tuple[int, int] = None, - image_size: Tuple[int, int] = (800, 600)) -> bool: +def create_comparison_grid( + shapes: List["Shape"], + shape_names: Optional[List[str]], + output_path: Union[str, Path], + grid_size: Tuple[int, int] = None, + image_size: Tuple[int, int] = (800, 600), +) -> bool: """ Create a comparison grid showing multiple shapes from standard views. @@ -629,7 +817,7 @@ def create_comparison_grid(shapes: List['Shape'], # Create renderer with appropriate size settings settings = RenderSettings(width=image_size[0], height=image_size[1]) renderer = MultiViewRenderer(settings) - + # Determine views based on grid_size views = [ViewAngle.FRONT, ViewAngle.RIGHT, ViewAngle.TOP, ViewAngle.ISOMETRIC] if grid_size: @@ -637,15 +825,17 @@ def create_comparison_grid(shapes: List['Shape'], total_views = grid_size[0] * grid_size[1] if total_views < len(views): views = views[:total_views] - + # Call the class method to perform the actual rendering return renderer.render_comparison_grid(shapes, output_path, views, shape_names) -def render_animation_frames(shape: 'Shape', - transformations: List[np.ndarray], - output_dir: Union[str, Path], - image_size: Tuple[int, int] = (800, 600)) -> List[Path]: +def render_animation_frames( + shape: "Shape", + transformations: List[np.ndarray], + output_dir: Union[str, Path], + image_size: Tuple[int, int] = (800, 600), +) -> List[Path]: """ Render frames for an animation based on a sequence of transformations. @@ -661,38 +851,38 @@ def render_animation_frames(shape: 'Shape', # Create renderer with appropriate size settings settings = RenderSettings(width=image_size[0], height=image_size[1]) renderer = MultiViewRenderer(settings) - + output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) - + frame_paths = [] - + # For each transformation, create a custom view direction for i, transform in enumerate(transformations): # Extract view direction from transformation matrix # This assumes the transform includes camera position changes direction = (-transform[0, 2], -transform[1, 2], -transform[2, 2]) - + filename = f"frame_{i:03d}.{settings.image_format.lower()}" frame_path = output_dir / filename - + success = renderer.render_view(shape, ViewAngle.CUSTOM, frame_path, direction) if success: frame_paths.append(frame_path) logging.info(f"Rendered frame {i+1}/{len(transformations)}") else: logging.error(f"Failed to render frame {i+1}") - + return frame_paths # Export all public functions and classes __all__ = [ - 'ViewAngle', - 'RenderSettings', - 'MultiViewRenderer', - 'BatchProcessor', - 'create_documentation_images', - 'create_comparison_grid', - 'render_animation_frames' -] \ No newline at end of file + "ViewAngle", + "RenderSettings", + "MultiViewRenderer", + "BatchProcessor", + "create_documentation_images", + "create_comparison_grid", + "render_animation_frames", +] diff --git a/src/pyocc/comparison.py b/src/pyocc/comparison.py index 019d2d4..fd49b2b 100644 --- a/src/pyocc/comparison.py +++ b/src/pyocc/comparison.py @@ -4,23 +4,21 @@ This module provides advanced comparison, hashing, and similarity analysis for CAD shapes beyond basic pointer equality. """ + import hashlib -import numpy as np -from typing import Dict, Any, Optional, Tuple, TYPE_CHECKING from enum import Enum +from typing import TYPE_CHECKING, Any, Dict, Tuple -from OCC.Core.BRepTools import BRepTools_ShapeSet -from OCC.Core.BRepGProp import brepgprop_VolumeProperties, brepgprop_SurfaceProperties -from OCC.Core.GProp import GProp_GProps +import numpy as np from OCC.Core.Bnd import Bnd_Box +from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.BRepBndLib import brepbndlib_Add -from OCC.Core.BRepCheck import BRepCheck_Analyzer -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.TopAbs import TopAbs_VERTEX, TopAbs_EDGE, TopAbs_FACE, TopAbs_SOLID -from OCC.Core.BRep import BRep_Tool +from OCC.Core.BRepGProp import brepgprop_SurfaceProperties, brepgprop_VolumeProperties from OCC.Core.GeomLProp import GeomLProp_SLProps -from OCC.Core.BRepAdaptor import BRepAdaptor_Surface +from OCC.Core.GProp import GProp_GProps from OCC.Core.Precision import precision_Confusion +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SOLID, TopAbs_VERTEX +from OCC.Core.TopExp import TopExp_Explorer if TYPE_CHECKING: from .shape import Shape @@ -28,10 +26,11 @@ class ComparisonLevel(Enum): """Levels of shape comparison detail.""" - POINTER = "pointer" # Exact pointer comparison (fastest) - TOPOLOGY = "topology" # Topological structure comparison - GEOMETRY = "geometry" # Geometric properties comparison - DETAILED = "detailed" # Detailed geometric analysis (slowest) + + POINTER = "pointer" # Exact pointer comparison (fastest) + TOPOLOGY = "topology" # Topological structure comparison + GEOMETRY = "geometry" # Geometric properties comparison + DETAILED = "detailed" # Detailed geometric analysis (slowest) class ShapeFingerprint: @@ -42,7 +41,7 @@ class ShapeFingerprint: used for fast shape comparison and similarity analysis. """ - def __init__(self, shape: 'Shape'): + def __init__(self, shape: "Shape"): """ Create a fingerprint for the given shape. @@ -90,7 +89,7 @@ def _compute_volume(self, shape) -> float: props = GProp_GProps() brepgprop_VolumeProperties(shape, props) return props.Mass() - except: + except Exception: return 0.0 def _compute_surface_area(self, shape) -> float: @@ -99,7 +98,7 @@ def _compute_surface_area(self, shape) -> float: props = GProp_GProps() brepgprop_SurfaceProperties(shape, props) return props.Mass() - except: + except Exception: return 0.0 def _compute_bounding_box(self, shape) -> Tuple[np.ndarray, np.ndarray]: @@ -109,11 +108,8 @@ def _compute_bounding_box(self, shape) -> Tuple[np.ndarray, np.ndarray]: brepbndlib_Add(shape, bbox) if not bbox.IsVoid(): xmin, ymin, zmin, xmax, ymax, zmax = bbox.Get() - return ( - np.array([xmin, ymin, zmin]), - np.array([xmax, ymax, zmax]) - ) - except: + return (np.array([xmin, ymin, zmin]), np.array([xmax, ymax, zmax])) + except Exception: pass return (np.zeros(3), np.zeros(3)) @@ -132,7 +128,7 @@ def _compute_center_of_mass(self, shape) -> np.ndarray: if props.Mass() > precision_Confusion(): center = props.CentreOfMass() return np.array([center.X(), center.Y(), center.Z()]) - except: + except Exception: pass return np.zeros(3) @@ -163,22 +159,22 @@ def _compute_signature(self) -> str: def to_dict(self) -> Dict[str, Any]: """Convert fingerprint to dictionary.""" return { - 'num_vertices': self.num_vertices, - 'num_edges': self.num_edges, - 'num_faces': self.num_faces, - 'num_solids': self.num_solids, - 'volume': self.volume, - 'surface_area': self.surface_area, - 'bounding_box_min': self.bounding_box[0].tolist(), - 'bounding_box_max': self.bounding_box[1].tolist(), - 'center_of_mass': self.center_of_mass.tolist(), - 'diagonal_length': self.diagonal_length, - 'compactness': self.compactness, - 'signature': self.signature + "num_vertices": self.num_vertices, + "num_edges": self.num_edges, + "num_faces": self.num_faces, + "num_solids": self.num_solids, + "volume": self.volume, + "surface_area": self.surface_area, + "bounding_box_min": self.bounding_box[0].tolist(), + "bounding_box_max": self.bounding_box[1].tolist(), + "center_of_mass": self.center_of_mass.tolist(), + "diagonal_length": self.diagonal_length, + "compactness": self.compactness, + "signature": self.signature, } -def compute_shape_hash(shape: 'Shape', level: ComparisonLevel = ComparisonLevel.GEOMETRY) -> str: +def compute_shape_hash(shape: "Shape", level: ComparisonLevel = ComparisonLevel.GEOMETRY) -> str: """ Compute a hash string for a shape based on its geometric properties. @@ -212,9 +208,12 @@ def compute_shape_hash(shape: 'Shape', level: ComparisonLevel = ComparisonLevel. raise ValueError(f"Unknown comparison level: {level}") -def shapes_equal(shape1: 'Shape', shape2: 'Shape', - level: ComparisonLevel = ComparisonLevel.GEOMETRY, - tolerance: float = 1e-6) -> bool: +def shapes_equal( + shape1: "Shape", + shape2: "Shape", + level: ComparisonLevel = ComparisonLevel.GEOMETRY, + tolerance: float = 1e-6, +) -> bool: """ Compare two shapes for equality at the specified level of detail. @@ -235,10 +234,12 @@ def shapes_equal(shape1: 'Shape', shape2: 'Shape', # Compare topological structure fp1 = ShapeFingerprint(shape1) fp2 = ShapeFingerprint(shape2) - return (fp1.num_vertices == fp2.num_vertices and - fp1.num_edges == fp2.num_edges and - fp1.num_faces == fp2.num_faces and - fp1.num_solids == fp2.num_solids) + return ( + fp1.num_vertices == fp2.num_vertices + and fp1.num_edges == fp2.num_edges + and fp1.num_faces == fp2.num_faces + and fp1.num_solids == fp2.num_solids + ) elif level == ComparisonLevel.GEOMETRY: # Compare geometric properties @@ -254,7 +255,7 @@ def shapes_equal(shape1: 'Shape', shape2: 'Shape', raise ValueError(f"Unknown comparison level: {level}") -def shape_similarity(shape1: 'Shape', shape2: 'Shape') -> float: +def shape_similarity(shape1: "Shape", shape2: "Shape") -> float: """ Compute similarity score between two shapes (0.0 to 1.0). @@ -290,23 +291,26 @@ def shape_similarity(shape1: 'Shape', shape2: 'Shape') -> float: return sum(scores) -def _geometric_properties_equal(fp1: ShapeFingerprint, fp2: ShapeFingerprint, - tolerance: float) -> bool: +def _geometric_properties_equal( + fp1: ShapeFingerprint, fp2: ShapeFingerprint, tolerance: float +) -> bool: """Compare geometric properties of two fingerprints.""" - return (abs(fp1.volume - fp2.volume) < tolerance and - abs(fp1.surface_area - fp2.surface_area) < tolerance and - abs(fp1.diagonal_length - fp2.diagonal_length) < tolerance and - np.allclose(fp1.center_of_mass, fp2.center_of_mass, atol=tolerance)) + return ( + abs(fp1.volume - fp2.volume) < tolerance + and abs(fp1.surface_area - fp2.surface_area) < tolerance + and abs(fp1.diagonal_length - fp2.diagonal_length) < tolerance + and np.allclose(fp1.center_of_mass, fp2.center_of_mass, atol=tolerance) + ) -def _detailed_shapes_equal(shape1: 'Shape', shape2: 'Shape', tolerance: float) -> bool: +def _detailed_shapes_equal(shape1: "Shape", shape2: "Shape", tolerance: float) -> bool: """Perform detailed shape comparison.""" # This is a placeholder for more sophisticated comparison # Could include vertex-by-vertex comparison, curve analysis, etc. return shapes_equal(shape1, shape2, ComparisonLevel.GEOMETRY, tolerance) -def _compute_detailed_hash(shape: 'Shape') -> str: +def _compute_detailed_hash(shape: "Shape") -> str: """Compute a detailed hash including shape geometry.""" # This is a simplified implementation # A full implementation would analyze curve parameters, surface equations, etc. @@ -333,7 +337,7 @@ def _compute_detailed_hash(shape: 'Shape') -> str: k1 = props.MaxCurvature() k2 = props.MinCurvature() detail_data.append(f"{k1:.6f}:{k2:.6f}") - except: + except Exception: pass explorer.Next() @@ -346,9 +350,14 @@ def _compute_detailed_hash(shape: 'Shape') -> str: def _topology_similarity(fp1: ShapeFingerprint, fp2: ShapeFingerprint) -> float: """Compute topological similarity between fingerprints.""" # Compare entity counts - if (fp1.num_vertices == 0 and fp2.num_vertices == 0 and - fp1.num_edges == 0 and fp2.num_edges == 0 and - fp1.num_faces == 0 and fp2.num_faces == 0): + if ( + fp1.num_vertices == 0 + and fp2.num_vertices == 0 + and fp1.num_edges == 0 + and fp2.num_edges == 0 + and fp1.num_faces == 0 + and fp2.num_faces == 0 + ): return 1.0 total_entities_1 = fp1.num_vertices + fp1.num_edges + fp1.num_faces + fp1.num_solids @@ -389,8 +398,9 @@ def _count_similarity(count1: int, count2: int) -> float: return min(count1, count2) / max(count1, count2) -def _bounding_box_similarity(bbox1: Tuple[np.ndarray, np.ndarray], - bbox2: Tuple[np.ndarray, np.ndarray]) -> float: +def _bounding_box_similarity( + bbox1: Tuple[np.ndarray, np.ndarray], bbox2: Tuple[np.ndarray, np.ndarray] +) -> float: """Compute bounding box similarity.""" min1, max1 = bbox1 min2, max2 = bbox2 @@ -413,9 +423,9 @@ def _bounding_box_similarity(bbox1: Tuple[np.ndarray, np.ndarray], # Export all public functions and classes __all__ = [ - 'ComparisonLevel', - 'ShapeFingerprint', - 'compute_shape_hash', - 'shapes_equal', - 'shape_similarity' + "ComparisonLevel", + "ShapeFingerprint", + "compute_shape_hash", + "shapes_equal", + "shape_similarity", ] diff --git a/src/pyocc/compound.py b/src/pyocc/compound.py index 06290c6..0b225a5 100644 --- a/src/pyocc/compound.py +++ b/src/pyocc/compound.py @@ -4,35 +4,41 @@ This module provides the Compound class which represents a collection of shapes that can be worked with as a single entity. """ + import logging -import numpy as np -from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_CompSolid -from OCC.Core.BRep import BRep_Tool, BRep_Builder + +from OCC.Core.BRep import BRep_Builder from OCC.Core.BRepTools import BRepTools_ShapeSet -from OCC.Core.STEPControl import STEPControl_Reader from OCC.Core.IFSelect import IFSelect_RetDone -from OCC.Core.TopAbs import ( - TopAbs_FACE, TopAbs_EDGE, TopAbs_SHELL, - TopAbs_SOLID, TopAbs_COMPOUND, TopAbs_COMPSOLID, - TopAbs_WIRE, TopAbs_VERTEX -) -from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_CompSolid from OCC.Extend.DataExchange import read_step_file from .base import ( - BottomUpFaceIterator, BottomUpEdgeIterator, BoundingBoxMixin, - EdgeContainerMixin, FaceContainerMixin, ShellContainerMixin, SolidContainerMixin, - SurfacePropertiesMixin, TriangulatorMixin, VertexContainerMixin, - VolumePropertiesMixin, WireContainerMixin + BottomUpEdgeIterator, + BottomUpFaceIterator, + ShellContainerMixin, + SolidContainerMixin, + SurfacePropertiesMixin, + TriangulatorMixin, + VertexContainerMixin, + VolumePropertiesMixin, + WireContainerMixin, ) -# Remove direct import to avoid circular dependency -# from .shape import Shape - - -class Compound(BottomUpFaceIterator, BottomUpEdgeIterator, BoundingBoxMixin, - EdgeContainerMixin, FaceContainerMixin, ShellContainerMixin, SolidContainerMixin, - SurfacePropertiesMixin, TriangulatorMixin, VertexContainerMixin, - VolumePropertiesMixin, WireContainerMixin): +from .shape import Shape + + +class Compound( + Shape, + BottomUpFaceIterator, + BottomUpEdgeIterator, + ShellContainerMixin, + SolidContainerMixin, + SurfacePropertiesMixin, + TriangulatorMixin, + VertexContainerMixin, + VolumePropertiesMixin, + WireContainerMixin, +): """ A compound representing a collection of shapes. @@ -137,18 +143,17 @@ def load_from_occ_native(filename, verbosity=False): Compound object """ shape_set = BRepTools_ShapeSet() - with open(str(filename), 'r') as f: + with open(str(filename), "r") as f: shape_set.ReadFromString(f) shapes = [] for i in range(shape_set.NbShapes()): - shapes.append(shape_set.Shape(i+1)) + shapes.append(shape_set.Shape(i + 1)) # Use lazy import to avoid circular dependency from .shape import Shape - return Compound.make_from_shapes( - [Shape.pyocc_shape(s) for s in shapes] - ) + + return Compound.make_from_shapes([Shape.pyocc_shape(s) for s in shapes]) def has_solids(self): """ @@ -269,7 +274,7 @@ def __str__(self): return f"Compound({', '.join(parts)})" else: return "Compound(empty)" - except: + except Exception: return "Compound(invalid)" def save_to_step(self, filename, verbosity=False): @@ -283,8 +288,8 @@ def save_to_step(self, filename, verbosity=False): Returns: True if successful """ - from OCC.Core.STEPControl import STEPControl_Writer, STEPControl_AsIs from OCC.Core.Interface import Interface_Static + from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Writer try: # Set up STEP writer with AP203 schema (commonly used for CAD) @@ -318,21 +323,21 @@ def save_to_step(self, filename, verbosity=False): def fuse(self, other): """ Fuse this compound with another shape. - + Args: other: Another Shape object - + Returns: A new Compound representing the union """ # For now, return self as fusion is not implemented # This is a placeholder for the interface expected by Workplane return self - + def clean(self): """ Clean the compound by removing redundant geometry. - + Returns: A new cleaned Compound """ @@ -342,4 +347,5 @@ def clean(self): def to_shape(self): from .shape import Shape - return Shape.pyocc_shape(self.topods_shape()) \ No newline at end of file + + return Shape.pyocc_shape(self.topods_shape()) diff --git a/src/pyocc/constraints.py b/src/pyocc/constraints.py index d1ba02a..0a837a9 100644 --- a/src/pyocc/constraints.py +++ b/src/pyocc/constraints.py @@ -3,7 +3,7 @@ """ from enum import Enum, auto -from typing import List, Optional, Tuple, Union, Any +from typing import Any, List, Optional, Union from .edge import Edge from .vertex import Vertex @@ -11,6 +11,7 @@ class ConstraintType(Enum): """Types of constraints that can be applied to sketch entities.""" + COINCIDENT = auto() HORIZONTAL = auto() VERTICAL = auto() @@ -31,15 +32,17 @@ class Constraint: """ A constraint that can be applied to sketch entities. """ - - def __init__(self, - constraint_type: ConstraintType, - entities: List[Union[Edge, Vertex]], - value: Optional[float] = None, - reference: Optional[Any] = None): + + def __init__( + self, + constraint_type: ConstraintType, + entities: List[Union[Edge, Vertex]], + value: Optional[float] = None, + reference: Optional[Any] = None, + ): """ Initialize a constraint. - + Args: constraint_type: The type of constraint entities: The entities to constrain @@ -51,161 +54,170 @@ def __init__(self, self.value = value self.reference = reference self._validate() - + def _validate(self): """Validate that the constraint is properly defined.""" # Check that we have the right number of entities for the constraint type if self.constraint_type in [ConstraintType.COINCIDENT, ConstraintType.TANGENT]: if len(self.entities) != 2: - raise ValueError(f"{self.constraint_type.name} constraint requires exactly 2 entities") - + raise ValueError( + f"{self.constraint_type.name} constraint requires exactly 2 entities" + ) + elif self.constraint_type in [ConstraintType.HORIZONTAL, ConstraintType.VERTICAL]: if len(self.entities) != 1: - raise ValueError(f"{self.constraint_type.name} constraint requires exactly 1 entity") - + raise ValueError( + f"{self.constraint_type.name} constraint requires exactly 1 entity" + ) + elif self.constraint_type in [ConstraintType.DISTANCE, ConstraintType.ANGLE]: if len(self.entities) not in [1, 2]: raise ValueError(f"{self.constraint_type.name} constraint requires 1 or 2 entities") if self.value is None: raise ValueError(f"{self.constraint_type.name} constraint requires a value") - + elif self.constraint_type in [ConstraintType.RADIUS, ConstraintType.DIAMETER]: if len(self.entities) != 1: - raise ValueError(f"{self.constraint_type.name} constraint requires exactly 1 entity") + raise ValueError( + f"{self.constraint_type.name} constraint requires exactly 1 entity" + ) if self.value is None: raise ValueError(f"{self.constraint_type.name} constraint requires a value") - + @classmethod def coincident(cls, entity1: Union[Edge, Vertex], entity2: Union[Edge, Vertex]) -> "Constraint": """ Create a coincident constraint between two entities. - + Args: entity1: First entity entity2: Second entity - + Returns: A coincident constraint """ return cls(ConstraintType.COINCIDENT, [entity1, entity2]) - + @classmethod def horizontal(cls, edge: Edge) -> "Constraint": """ Create a horizontal constraint for an edge. - + Args: edge: The edge to constrain - + Returns: A horizontal constraint """ return cls(ConstraintType.HORIZONTAL, [edge]) - + @classmethod def vertical(cls, edge: Edge) -> "Constraint": """ Create a vertical constraint for an edge. - + Args: edge: The edge to constrain - + Returns: A vertical constraint """ return cls(ConstraintType.VERTICAL, [edge]) - + @classmethod def parallel(cls, edge1: Edge, edge2: Edge) -> "Constraint": """ Create a parallel constraint between two edges. - + Args: edge1: First edge edge2: Second edge - + Returns: A parallel constraint """ return cls(ConstraintType.PARALLEL, [edge1, edge2]) - + @classmethod def perpendicular(cls, edge1: Edge, edge2: Edge) -> "Constraint": """ Create a perpendicular constraint between two edges. - + Args: edge1: First edge edge2: Second edge - + Returns: A perpendicular constraint """ return cls(ConstraintType.PERPENDICULAR, [edge1, edge2]) - + @classmethod def tangent(cls, entity1: Edge, entity2: Edge) -> "Constraint": """ Create a tangent constraint between two edges. - + Args: entity1: First edge entity2: Second edge - + Returns: A tangent constraint """ return cls(ConstraintType.TANGENT, [entity1, entity2]) - + @classmethod - def distance(cls, entity1: Union[Edge, Vertex], - entity2: Optional[Union[Edge, Vertex]] = None, - distance: float = 0.0) -> "Constraint": + def distance( + cls, + entity1: Union[Edge, Vertex], + entity2: Optional[Union[Edge, Vertex]] = None, + distance: float = 0.0, + ) -> "Constraint": """ Create a distance constraint. - + Args: entity1: First entity entity2: Second entity (optional) distance: The distance value - + Returns: A distance constraint """ entities = [entity1] if entity2 is None else [entity1, entity2] return cls(ConstraintType.DISTANCE, entities, distance) - + @classmethod def angle(cls, edge1: Edge, edge2: Edge, angle: float) -> "Constraint": """ Create an angle constraint between two edges. - + Args: edge1: First edge edge2: Second edge angle: The angle value in degrees - + Returns: An angle constraint """ return cls(ConstraintType.ANGLE, [edge1, edge2], angle) - + @classmethod def radius(cls, edge: Edge, radius: float) -> "Constraint": """ Create a radius constraint for an arc or circle. - + Args: edge: The arc or circle edge radius: The radius value - + Returns: A radius constraint """ return cls(ConstraintType.RADIUS, [edge], radius) - + def __str__(self) -> str: """String representation of the constraint.""" entities_str = ", ".join(str(e) for e in self.entities) value_str = f", value={self.value}" if self.value is not None else "" - return f"{self.constraint_type.name}({entities_str}{value_str})" \ No newline at end of file + return f"{self.constraint_type.name}({entities_str}{value_str})" diff --git a/src/pyocc/context.py b/src/pyocc/context.py index f911d1b..f1ff696 100644 --- a/src/pyocc/context.py +++ b/src/pyocc/context.py @@ -8,32 +8,34 @@ - Error handling """ -from typing import Optional, Dict, Any, Union, Tuple, List -import numpy as np -from dataclasses import dataclass, field -from contextlib import contextmanager import logging +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple -# OCC imports -from OCC.Core.gp import gp_Pnt, gp_Vec, gp_Dir, gp_Ax1, gp_Ax2, gp_Trsf, gp_Mat -from OCC.Core.TopLoc import TopLoc_Location -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BinTools import BinTools_ShapeSet +from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Surface -from OCC.Core.GeomAbs import GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone, GeomAbs_Sphere, GeomAbs_Torus, GeomAbs_BezierSurface, GeomAbs_BSplineSurface -from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnSurf -from OCC.Core.Geom import Geom_Plane, Geom_CylindricalSurface, Geom_ConicalSurface, Geom_SphericalSurface, Geom_ToroidalSurface +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepGProp import brepgprop_SurfaceProperties, brepgprop_VolumeProperties -from OCC.Core.GProp import GProp_GProps from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh +from OCC.Core.GeomAbs import ( + GeomAbs_BezierSurface, + GeomAbs_BSplineSurface, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_Torus, +) +from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnSurf + +# OCC imports +from OCC.Core.gp import gp_Pnt, gp_Trsf +from OCC.Core.GProp import GProp_GProps from OCC.Core.StlAPI import StlAPI_Writer -from OCC.Core.BRep import BRep_Tool -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_WIRE, TopAbs_SHELL, TopAbs_SOLID, TopAbs_COMPOUND -from OCC.Core.TopTools import TopTools_IndexedMapOfShape -from OCC.Core.BRepCheck import BRepCheck_Analyzer -from OCC.Core.BRepTools import BRepTools_ShapeSet -from OCC.Core.BinTools import BinTools_ShapeSet -from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Face, TopoDS_Edge, TopoDS_Vertex, TopoDS_Wire, TopoDS_Shell, TopoDS_Solid, TopoDS_Compound +from OCC.Core.TopoDS import TopoDS_Face, TopoDS_Shape, TopoDS_Solid @dataclass @@ -241,11 +243,11 @@ def calculate_surface_properties(self, face: TopoDS_Face) -> Dict[str, Any]: """Calculate surface properties using the current context.""" if not self._current_context.enable_properties_calculation: return {} - + try: props = GProp_GProps() brepgprop_SurfaceProperties(face, props) - + return { "area": props.Mass(), "center_of_mass": ( @@ -258,16 +260,16 @@ def calculate_surface_properties(self, face: TopoDS_Face) -> Dict[str, Any]: except Exception as e: self.handle_error("Surface properties calculation failed", e) return {} - + def calculate_volume_properties(self, solid: TopoDS_Solid) -> Dict[str, Any]: """Calculate volume properties using the current context.""" if not self._current_context.enable_properties_calculation: return {} - + try: props = GProp_GProps() brepgprop_VolumeProperties(solid, props) - + return { "volume": props.Mass(), "center_of_mass": ( @@ -302,7 +304,7 @@ def generate_mesh(self, shape: TopoDS_Shape) -> bool: def get_surface_type(self, face: TopoDS_Face) -> str: """Get the surface type of a face.""" try: - surface = BRep_Tool.Surface(face) + BRep_Tool.Surface(face) adaptor = BRepAdaptor_Surface(face) surface_type = adaptor.GetType() diff --git a/src/pyocc/display_data.py b/src/pyocc/display_data.py index 4ab25bb..7409ed8 100644 --- a/src/pyocc/display_data.py +++ b/src/pyocc/display_data.py @@ -5,19 +5,16 @@ for use with external viewers and renderers. It extracts geometric information in a format suitable for visualization systems. """ -import numpy as np -from typing import Dict, List, Tuple, Optional, Any, Union -from dataclasses import dataclass -from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_REVERSED -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh +from dataclasses import dataclass, field +from typing import List, Optional, Tuple, Union + +import numpy as np from OCC.Core.BRep import BRep_Tool -from OCC.Core.Poly import Poly_Triangulation -from OCC.Core.TColgp import TColgp_Array1OfPnt -from OCC.Core.Quantity import Quantity_Color, Quantity_TOC_RGB -from OCC.Core.gp import gp_Pnt +from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.Precision import precision_Confusion +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_REVERSED, TopAbs_VERTEX +from OCC.Core.TopExp import TopExp_Explorer from .geometry.geom_utils import gp_to_numpy @@ -25,6 +22,7 @@ @dataclass class DisplayVertex: """Represents a vertex for display purposes.""" + position: np.ndarray index: int @@ -32,8 +30,9 @@ class DisplayVertex: @dataclass class DisplayEdge: """Represents an edge for display purposes.""" + vertices: List[int] # Indices into vertex list - points: np.ndarray # Polyline points along edge + points: np.ndarray # Polyline points along edge is_seam: bool = False is_boundary: bool = False @@ -41,11 +40,12 @@ class DisplayEdge: @dataclass class DisplayFace: """Represents a face for display purposes.""" - vertices: np.ndarray # Vertex coordinates (Nx3) - triangles: np.ndarray # Triangle indices (Mx3) - normals: np.ndarray # Vertex normals (Nx3) - uv_coords: np.ndarray # UV coordinates (Nx2) - face_normal: np.ndarray # Face normal + + vertices: np.ndarray # Vertex coordinates (Nx3) + triangles: np.ndarray # Triangle indices (Mx3) + normals: np.ndarray # Vertex normals (Nx3) + uv_coords: np.ndarray # UV coordinates (Nx2) + face_normal: np.ndarray # Face normal area: float surface_type: str @@ -53,16 +53,18 @@ class DisplayFace: @dataclass class DisplayMaterial: """Material properties for display.""" - color: np.ndarray = np.array([0.7, 0.7, 0.7]) # RGB + + color: np.ndarray = field(default_factory=lambda: np.array([0.7, 0.7, 0.7])) # RGB transparency: float = 0.0 shininess: float = 0.3 - specular: np.ndarray = np.array([0.2, 0.2, 0.2]) - ambient: np.ndarray = np.array([0.2, 0.2, 0.2]) + specular: np.ndarray = field(default_factory=lambda: np.array([0.2, 0.2, 0.2])) + ambient: np.ndarray = field(default_factory=lambda: np.array([0.2, 0.2, 0.2])) @dataclass class DisplayShape: """Complete display representation of a shape.""" + vertices: List[DisplayVertex] edges: List[DisplayEdge] faces: List[DisplayFace] @@ -121,7 +123,7 @@ def extract_shape_data(self, shape, material: Optional[DisplayMaterial] = None) faces=faces, material=material, bounding_box=bbox, - shape_type=self._get_shape_type(shape) + shape_type=self._get_shape_type(shape), ) def extract_mesh_data(self, shape) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: @@ -170,15 +172,21 @@ def extract_mesh_data(self, shape) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: # Adjust for face orientation if face.Orientation() == TopAbs_REVERSED: - face_triangles.append([n1-1 + vertex_offset, n3-1 + vertex_offset, n2-1 + vertex_offset]) + face_triangles.append( + [n1 - 1 + vertex_offset, n3 - 1 + vertex_offset, n2 - 1 + vertex_offset] + ) else: - face_triangles.append([n1-1 + vertex_offset, n2-1 + vertex_offset, n3-1 + vertex_offset]) + face_triangles.append( + [n1 - 1 + vertex_offset, n2 - 1 + vertex_offset, n3 - 1 + vertex_offset] + ) face_triangles = np.array(face_triangles) all_triangles.append(face_triangles) # Compute normals for this face's vertices - face_normals = self._compute_vertex_normals(face_vertices, face_triangles - vertex_offset) + face_normals = self._compute_vertex_normals( + face_vertices, face_triangles - vertex_offset + ) all_normals.append(face_normals) vertex_offset += len(face_vertices) @@ -219,6 +227,7 @@ def extract_wireframe_data(self, shape) -> Tuple[np.ndarray, np.ndarray]: # Get edge polyline try: from pyocc.edge import Edge + edge_obj = Edge(edge) points = edge_obj.get_polyline(self.linear_deflection) @@ -299,8 +308,9 @@ def extract_face_data(self, shape) -> List[DisplayFace]: def _tessellate_shape(self, shape): """Tessellate a shape for display.""" try: - mesh = BRepMesh_IncrementalMesh(shape.topods_shape(), self.linear_deflection, - False, self.angular_deflection) + mesh = BRepMesh_IncrementalMesh( + shape.topods_shape(), self.linear_deflection, False, self.angular_deflection + ) mesh.Perform() except Exception: # Fallback with default parameters @@ -354,23 +364,27 @@ def _extract_edges(self, shape, vertices: List[DisplayVertex]) -> List[DisplayEd # Get edge polyline try: from pyocc.edge import Edge + edge_obj = Edge(edge) points = edge_obj.get_polyline(self.linear_deflection) points_array = np.array(points) except Exception: # Fallback to simple line if len(edge_vertices) >= 2: - points_array = np.array([vertices[edge_vertices[0]].position, - vertices[edge_vertices[1]].position]) + points_array = np.array( + [vertices[edge_vertices[0]].position, vertices[edge_vertices[1]].position] + ) else: points_array = np.empty((0, 3)) - edges.append(DisplayEdge( - vertices=edge_vertices, - points=points_array, - is_seam=False, # Could be enhanced to detect seams - is_boundary=True # Could be enhanced to detect boundary vs internal - )) + edges.append( + DisplayEdge( + vertices=edge_vertices, + points=points_array, + is_seam=False, # Could be enhanced to detect seams + is_boundary=True, # Could be enhanced to detect boundary vs internal + ) + ) edge_explorer.Next() @@ -417,9 +431,9 @@ def _extract_faces(self, shape) -> List[DisplayFace]: # Adjust for face orientation if face.Orientation() == TopAbs_REVERSED: - triangles.append([n1-1, n3-1, n2-1]) + triangles.append([n1 - 1, n3 - 1, n2 - 1]) else: - triangles.append([n1-1, n2-1, n3-1]) + triangles.append([n1 - 1, n2 - 1, n3 - 1]) triangles = np.array(triangles) @@ -432,15 +446,17 @@ def _extract_faces(self, shape) -> List[DisplayFace]: # Get surface type surface_type = self._get_surface_type(face) - faces.append(DisplayFace( - vertices=vertices, - triangles=triangles, - normals=normals, - uv_coords=uv_coords, - face_normal=face_normal, - area=area, - surface_type=surface_type - )) + faces.append( + DisplayFace( + vertices=vertices, + triangles=triangles, + normals=normals, + uv_coords=uv_coords, + face_normal=face_normal, + area=area, + surface_type=surface_type, + ) + ) face_explorer.Next() @@ -490,7 +506,9 @@ def _compute_vertex_normals(self, vertices: np.ndarray, triangles: np.ndarray) - return vertex_normals - def _compute_face_properties(self, vertices: np.ndarray, triangles: np.ndarray) -> Tuple[np.ndarray, float]: + def _compute_face_properties( + self, vertices: np.ndarray, triangles: np.ndarray + ) -> Tuple[np.ndarray, float]: """Compute face normal and area.""" if len(triangles) == 0: return np.array([0.0, 0.0, 1.0]), 0.0 @@ -540,6 +558,7 @@ def _get_surface_type(self, face) -> str: """Get the surface type of a face.""" try: from pyocc.face import Face + face_obj = Face(face) return face_obj.surface_type() except Exception: @@ -559,17 +578,17 @@ def create_color_array(color: Union[str, tuple, list, np.ndarray]) -> np.ndarray if isinstance(color, str): # Named colors color_map = { - 'red': [1.0, 0.0, 0.0], - 'green': [0.0, 1.0, 0.0], - 'blue': [0.0, 0.0, 1.0], - 'yellow': [1.0, 1.0, 0.0], - 'magenta': [1.0, 0.0, 1.0], - 'cyan': [0.0, 1.0, 1.0], - 'white': [1.0, 1.0, 1.0], - 'black': [0.0, 0.0, 0.0], - 'gray': [0.5, 0.5, 0.5], - 'orange': [1.0, 0.5, 0.0], - 'purple': [0.5, 0.0, 0.5] + "red": [1.0, 0.0, 0.0], + "green": [0.0, 1.0, 0.0], + "blue": [0.0, 0.0, 1.0], + "yellow": [1.0, 1.0, 0.0], + "magenta": [1.0, 0.0, 1.0], + "cyan": [0.0, 1.0, 1.0], + "white": [1.0, 1.0, 1.0], + "black": [0.0, 0.0, 0.0], + "gray": [0.5, 0.5, 0.5], + "orange": [1.0, 0.5, 0.0], + "purple": [0.5, 0.0, 0.5], } return np.array(color_map.get(color.lower(), [0.7, 0.7, 0.7])) @@ -606,13 +625,20 @@ def create_material(color=None, transparency=0.0, shininess=0.3) -> DisplayMater transparency=transparency, shininess=shininess, specular=color_array * 0.3, - ambient=color_array * 0.3 + ambient=color_array * 0.3, ) -def get_shape_display_data(shape, color=None, transparency=0.0, include_normals=False, - include_colors=False, include_texture_coords=False, - deflection=None, angular_deflection=None): +def get_shape_display_data( + shape, + color=None, + transparency=0.0, + include_normals=False, + include_colors=False, + include_texture_coords=False, + deflection=None, + angular_deflection=None, +): """ Extract display data from a shape. @@ -632,42 +658,38 @@ def get_shape_display_data(shape, color=None, transparency=0.0, include_normals= # Create extractor with specified deflection values extractor = DisplayDataExtractor( linear_deflection=deflection if deflection is not None else 0.1, - angular_deflection=angular_deflection if angular_deflection is not None else 0.1 + angular_deflection=angular_deflection if angular_deflection is not None else 0.1, ) # Create material if color is provided - material = None if color is not None: - material = create_material(color, transparency) + create_material(color, transparency) # Extract mesh data vertices, triangles, normals = extractor.extract_mesh_data(shape) # Build result dictionary - result = { - 'vertices': vertices, - 'triangles': triangles - } + result = {"vertices": vertices, "triangles": triangles} # Add optional data if include_normals: - result['normals'] = normals + result["normals"] = normals if include_colors: if color is not None: - result['colors'] = np.tile(create_color_array(color), (len(vertices), 1)) + result["colors"] = np.tile(create_color_array(color), (len(vertices), 1)) else: - result['colors'] = np.tile(np.array([0.7, 0.7, 0.7]), (len(vertices), 1)) + result["colors"] = np.tile(np.array([0.7, 0.7, 0.7]), (len(vertices), 1)) if include_texture_coords: # Just provide default UV coordinates - result['texture_coords'] = np.zeros((len(vertices), 2)) + result["texture_coords"] = np.zeros((len(vertices), 2)) if color is not None and not include_colors: - result['color'] = create_color_array(color) + result["color"] = create_color_array(color) if transparency > 0: - result['transparency'] = transparency + result["transparency"] = transparency return result @@ -706,7 +728,8 @@ def get_edge_display_data(edge, color=None, transparency=0.0, num_points=20): try: from pyocc.edge import Edge - edge_obj = Edge(edge.topods_shape() if hasattr(edge, 'topods_shape') else edge) + + edge_obj = Edge(edge.topods_shape() if hasattr(edge, "topods_shape") else edge) points = edge_obj.get_polyline(deflection=0.1, num_points=num_points) points = np.array(points) except Exception: @@ -714,15 +737,15 @@ def get_edge_display_data(edge, color=None, transparency=0.0, num_points=20): points, _ = extractor.extract_wireframe_data(edge) result = { - 'vertices': points, - 'points': points # Include both 'vertices' and 'points' keys for compatibility + "vertices": points, + "points": points, # Include both 'vertices' and 'points' keys for compatibility } if color is not None: - result['color'] = create_color_array(color) + result["color"] = create_color_array(color) if transparency > 0: - result['transparency'] = transparency + result["transparency"] = transparency return result @@ -739,10 +762,7 @@ def extract_triangulation_data(shape): """ extractor = DisplayDataExtractor() vertices, triangles, _ = extractor.extract_mesh_data(shape) - return { - 'vertices': vertices, - 'triangles': triangles - } + return {"vertices": vertices, "triangles": triangles} def extract_wireframe_data(shape, linear_deflection=0.1, angular_deflection=0.1): @@ -759,10 +779,7 @@ def extract_wireframe_data(shape, linear_deflection=0.1, angular_deflection=0.1) """ extractor = DisplayDataExtractor(linear_deflection, angular_deflection) points, lines = extractor.extract_wireframe_data(shape) - return { - 'points': points, - 'lines': lines - } + return {"points": points, "lines": lines} def compute_vertex_normals(vertices, triangles): diff --git a/src/pyocc/edge.py b/src/pyocc/edge.py index 0d4a332..6fac5ff 100644 --- a/src/pyocc/edge.py +++ b/src/pyocc/edge.py @@ -3,23 +3,32 @@ This module provides the Edge class which represents a curve in 3D space. """ -import numpy as np + import logging -from OCC.Core.TopoDS import TopoDS_Edge + +import numpy as np from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_Curve from OCC.Core.GCPnts import GCPnts_AbscissaPoint, GCPnts_UniformAbscissa from OCC.Core.GeomAbs import ( - GeomAbs_Line, GeomAbs_Circle, GeomAbs_Ellipse, GeomAbs_Hyperbola, - GeomAbs_Parabola, GeomAbs_BezierCurve, GeomAbs_BSplineCurve, - GeomAbs_OtherCurve + GeomAbs_BezierCurve, + GeomAbs_BSplineCurve, + GeomAbs_Circle, + GeomAbs_Ellipse, + GeomAbs_Hyperbola, + GeomAbs_Line, + GeomAbs_OtherCurve, + GeomAbs_Parabola, ) from OCC.Core.ShapeAnalysis import ShapeAnalysis_Edge +from OCC.Core.TopoDS import TopoDS_Edge +from .base import VertexContainerMixin from .geometry.interval import Interval +from .shape import Shape -class Edge: +class Edge(Shape, VertexContainerMixin): """ An edge representing a curve in 3D space. @@ -39,9 +48,8 @@ def make_line_from_points(start_point, end_point): Returns: Edge object representing the line """ - from OCC.Core.gp import gp_Pnt from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge - from .vertex import Vertex + from OCC.Core.gp import gp_Pnt # Create OCC points start_pnt = gp_Pnt(float(start_point[0]), float(start_point[1]), float(start_point[2])) @@ -67,8 +75,7 @@ def make_line_from_vertices(start_vertex, end_vertex): from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge edge_maker = BRepBuilderAPI_MakeEdge( - start_vertex.topods_vertex(), - end_vertex.topods_vertex() + start_vertex.topods_vertex(), end_vertex.topods_vertex() ) if not edge_maker.IsDone(): return None @@ -88,8 +95,8 @@ def make_circle(center, radius, direction=(0, 0, 1)): Returns: Edge object representing the circle """ - from OCC.Core.gp import gp_Circ, gp_Ax2, gp_Pnt, gp_Dir from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + from OCC.Core.gp import gp_Ax2, gp_Circ, gp_Dir, gp_Pnt # Create center point center_pnt = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) @@ -125,9 +132,9 @@ def make_arc_of_circle(pt1, pt2, pt3): Returns: Edge object representing the arc, or None if creation fails """ - from OCC.Core.gp import gp_Pnt - from OCC.Core.GC import GC_MakeArcOfCircle from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + from OCC.Core.GC import GC_MakeArcOfCircle + from OCC.Core.gp import gp_Pnt try: # Create OCC points @@ -217,7 +224,7 @@ def curve(self): """ try: curve_result = BRep_Tool.Curve(self._shape) # type: ignore - + # Handle different return types from BRep_Tool.Curve if isinstance(curve_result, tuple): if len(curve_result) == 3: @@ -228,7 +235,9 @@ def curve(self): # If only 2 values returned, assume last parameter is same as first return curve else: - raise ValueError(f"BRep_Tool.Curve returned unexpected tuple length: {len(curve_result)}") + raise ValueError( + f"BRep_Tool.Curve returned unexpected tuple length: {len(curve_result)}" + ) else: # Single value returned return curve_result @@ -252,24 +261,31 @@ def specific_curve(self): if curve_type == GeomAbs_Line: from OCC.Core.Geom import Geom_Line + return Geom_Line.DownCast(curve) # type: ignore elif curve_type == GeomAbs_Circle: from OCC.Core.Geom import Geom_Circle + return Geom_Circle.DownCast(curve) # type: ignore elif curve_type == GeomAbs_Ellipse: from OCC.Core.Geom import Geom_Ellipse + return Geom_Ellipse.DownCast(curve) # type: ignore elif curve_type == GeomAbs_Hyperbola: from OCC.Core.Geom import Geom_Hyperbola + return Geom_Hyperbola.DownCast(curve) # type: ignore elif curve_type == GeomAbs_Parabola: from OCC.Core.Geom import Geom_Parabola + return Geom_Parabola.DownCast(curve) # type: ignore elif curve_type == GeomAbs_BezierCurve: from OCC.Core.Geom import Geom_BezierCurve + return Geom_BezierCurve.DownCast(curve) # type: ignore elif curve_type == GeomAbs_BSplineCurve: from OCC.Core.Geom import Geom_BSplineCurve + return Geom_BSplineCurve.DownCast(curve) # type: ignore else: # Return the generic curve for other types @@ -332,7 +348,7 @@ def point(self, u): param = bounds.interpolate(u) else: # Actual parameter value - clamp to bounds - param = max(bounds.start, min(bounds.end, float(u))) # type: ignore + param = max(bounds.a, min(bounds.b, float(u))) # type: ignore else: raise ValueError(f"Parameter u must be a number, got {type(u)}") @@ -358,13 +374,13 @@ def tangent(self, u): adaptor = self._get_adaptor_curve() # Get first derivative - pnt = adaptor.Value(param) + adaptor.Value(param) vec = adaptor.DN(param, 1) # Normalize to unit vector - mag = np.sqrt(vec.X()**2 + vec.Y()**2 + vec.Z()**2) + mag = np.sqrt(vec.X() ** 2 + vec.Y() ** 2 + vec.Z() ** 2) if mag > 1e-10: # Avoid division by near-zero - return np.array([vec.X()/mag, vec.Y()/mag, vec.Z()/mag]) + return np.array([vec.X() / mag, vec.Y() / mag, vec.Z() / mag]) else: return np.array([0.0, 0.0, 0.0]) @@ -406,7 +422,6 @@ def closed_edge(self): True if the edge forms a loop """ # Check if the edge's endpoints are the same - from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Sewing # Use the curve's closedness for now # More robust implementation would check if start and end vertices are identical @@ -472,7 +487,7 @@ def curve_type(self): GeomAbs_Parabola: "parabola", GeomAbs_BezierCurve: "bezier", GeomAbs_BSplineCurve: "bspline", - GeomAbs_OtherCurve: "other" + GeomAbs_OtherCurve: "other", } return type_map.get(curve_type, "unknown") @@ -503,7 +518,9 @@ def get_polyline(self, points=20): try: # Try uniform abscissa sampling first (arc length based) - sampler = GCPnts_UniformAbscissa(adaptor, points, adaptor.FirstParameter(), adaptor.LastParameter()) + sampler = GCPnts_UniformAbscissa( + adaptor, points, adaptor.FirstParameter(), adaptor.LastParameter() + ) if sampler.IsDone() and sampler.NbPoints() > 0: # Extract the points @@ -534,7 +551,7 @@ def _get_polyline_fallback(self, points=20): numpy array of points on the curve """ try: - bounds = self.u_bounds() + self.u_bounds() params = np.linspace(0.0, 1.0, points) pts = [] @@ -559,15 +576,13 @@ def find_closest_point_data(self, datum): datum: Point to find closest point to (numpy array or list) Returns: - Dict with keys: 'point', 'parameter', 'distance', 'shape' + Tuple of (point, parameter, distance): + - point: numpy array [x, y, z] of the closest point on the edge + - parameter: normalized curve parameter (0..1) at the closest point + - distance: distance from datum to the closest point """ if not self.has_curve(): - return { - 'point': np.array(datum), - 'parameter': 0.0, - 'distance': 0.0, - 'shape': self - } + return np.array(datum), 0.0, 0.0 try: # Use OCC's extrema calculation @@ -599,12 +614,11 @@ def find_closest_point_data(self, datum): bounds = self.u_bounds() norm_param = bounds.normalize(closest_param) - return { - 'point': np.array([closest_pnt.X(), closest_pnt.Y(), closest_pnt.Z()]), - 'parameter': norm_param, - 'distance': distance, - 'shape': self - } + return ( + np.array([closest_pnt.X(), closest_pnt.Y(), closest_pnt.Z()]), + norm_param, + distance, + ) else: # Projection failed, use fallback method return self._find_closest_point_fallback(datum) @@ -627,12 +641,7 @@ def _find_closest_point_fallback(self, datum): # Sample the curve and find closest point pts = self.get_polyline(50) if len(pts) == 0: - return { - 'point': np.array(datum), - 'parameter': 0.0, - 'distance': 0.0, - 'shape': self - } + return np.array(datum), 0.0, 0.0 # Find closest sampled point datum_array = np.array(datum) @@ -643,21 +652,11 @@ def _find_closest_point_fallback(self, datum): # Approximate parameter based on index norm_param = idx / (len(pts) - 1) if len(pts) > 1 else 0.0 - return { - 'point': pts[idx], - 'parameter': norm_param, - 'distance': distances[idx], - 'shape': self - } + return pts[idx], norm_param, distances[idx] except Exception as e: logging.error(f"Fallback closest point failed: {e}") - return { - 'point': np.array(datum), - 'parameter': 0.0, - 'distance': 0.0, - 'shape': self - } + return np.array(datum), 0.0, 0.0 def start_vertex(self): """ @@ -668,6 +667,7 @@ def start_vertex(self): """ # Import inside method to avoid circular imports from .vertex import Vertex + return Vertex(ShapeAnalysis_Edge().FirstVertex(self.topods_shape())) # type: ignore def end_vertex(self): @@ -679,6 +679,7 @@ def end_vertex(self): """ # Import inside method to avoid circular imports from .vertex import Vertex + return Vertex(ShapeAnalysis_Edge().LastVertex(self.topods_shape())) # type: ignore def __str__(self): @@ -707,9 +708,9 @@ def __str__(self): info_str = f"Edge({curve_type}, length={length:.3f}, {closed}" if extra_info: info_str += f", {', '.join(extra_info)}" - info_str += f", param=[{bounds.start:.3f}, {bounds.end:.3f}])" # type: ignore + info_str += f", param=[{bounds.a:.3f}, {bounds.b:.3f}])" # type: ignore return info_str except Exception as e: - return f"Edge(error: {e})" \ No newline at end of file + return f"Edge(error: {e})" diff --git a/src/pyocc/edge_data_extractor.py b/src/pyocc/edge_data_extractor.py index fa85920..ed5850b 100644 --- a/src/pyocc/edge_data_extractor.py +++ b/src/pyocc/edge_data_extractor.py @@ -5,29 +5,29 @@ relationship between faces that meet at an edge, including convexity analysis and geometric data sampling along edges. """ -import numpy as np -from enum import Enum -from typing import List, Tuple, Dict, Optional + import logging +from enum import Enum -from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape -from OCC.Core.TopExp import TopExp_Explorer, topexp -from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE +import numpy as np from OCC.Core.BRep import BRep_Tool -from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface -from OCC.Core.GeomLProp import GeomLProp_CLProps, GeomLProp_SLProps -from OCC.Core.gp import gp_Pnt, gp_Vec, gp_Dir -from OCC.Core.Precision import precision_Confusion -from OCC.Core.BRepLib import BRepLib_FindSurface +from OCC.Core.BRepAdaptor import BRepAdaptor_Curve from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnSurf +from OCC.Core.GeomLProp import GeomLProp_CLProps +from OCC.Core.gp import gp_Pnt +from OCC.Core.Precision import precision_Confusion +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE +from OCC.Core.TopExp import topexp +from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape class EdgeConvexity(Enum): """Enum for edge convexity classification.""" - CONVEX = "convex" # Faces form an outward-pointing edge (like box corner) - CONCAVE = "concave" # Faces form an inward-pointing edge (like groove) - SMOOTH = "smooth" # Faces are tangent at the edge (like filleted corner) - UNDEFINED = "undefined" # Cannot determine or edge has only one face + + CONVEX = "convex" # Faces form an outward-pointing edge (like box corner) + CONCAVE = "concave" # Faces form an inward-pointing edge (like groove) + SMOOTH = "smooth" # Faces are tangent at the edge (like filleted corner) + UNDEFINED = "undefined" # Cannot determine or edge has only one face class EdgeDataExtractor: @@ -75,10 +75,7 @@ def get_adjacent_faces(self): # Use TopExp to find faces adjacent to the edge edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() topexp.MapShapesAndAncestors( - self.solid.topods_shape(), - TopAbs_EDGE, - TopAbs_FACE, - edge_face_map + self.solid.topods_shape(), TopAbs_EDGE, TopAbs_FACE, edge_face_map ) adjacent_faces = [] @@ -93,16 +90,18 @@ def get_adjacent_faces(self): face_shape = iterator.Value() # Import Face class here to avoid circular imports from pyocc.face import Face + adjacent_faces.append(Face(face_shape)) iterator.Next() - except: + except Exception: # Fallback: try converting to Python list try: faces_as_list = list(face_list) for face_shape in faces_as_list: from pyocc.face import Face + adjacent_faces.append(Face(face_shape)) - except: + except Exception: pass self._adjacent_faces = adjacent_faces @@ -145,6 +144,7 @@ def classify_convexity(self, num_samples=5): # Take the most common vote from collections import Counter + vote_counts = Counter(convexity_votes) return vote_counts.most_common(1)[0][0] @@ -265,10 +265,10 @@ def sample_geometric_data(self, num_samples=10): tangents[i] = self.edge.tangent(u) result = { - 'points': points, - 'tangents': tangents, - 'parameters': parameters, - 'convexity': self.classify_convexity(num_samples=min(num_samples, 5)) + "points": points, + "tangents": tangents, + "parameters": parameters, + "convexity": self.classify_convexity(num_samples=min(num_samples, 5)), } # Add face normals if adjacent faces are available @@ -281,11 +281,11 @@ def sample_geometric_data(self, num_samples=10): if uv is not None: try: normals_face1[i] = adjacent_faces[0].normal(uv) - except: + except Exception: normals_face1[i] = np.array([0, 0, 1]) # Default normal else: normals_face1[i] = np.array([0, 0, 1]) - result['normals_face1'] = normals_face1 + result["normals_face1"] = normals_face1 if len(adjacent_faces) >= 2: normals_face2 = np.zeros((num_samples, 3)) @@ -294,11 +294,11 @@ def sample_geometric_data(self, num_samples=10): if uv is not None: try: normals_face2[i] = adjacent_faces[1].normal(uv) - except: + except Exception: normals_face2[i] = np.array([0, 0, 1]) # Default normal else: normals_face2[i] = np.array([0, 0, 1]) - result['normals_face2'] = normals_face2 + result["normals_face2"] = normals_face2 return result @@ -334,14 +334,14 @@ def get_curvature_analysis(self, num_samples=10): else: curvatures[i] = 0.0 - except: + except Exception: curvatures[i] = 0.0 return { - 'parameters': parameters, - 'curvatures': curvatures, - 'max_curvature': np.max(curvatures), - 'min_curvature': np.min(curvatures) + "parameters": parameters, + "curvatures": curvatures, + "max_curvature": np.max(curvatures), + "min_curvature": np.min(curvatures), } def is_sharp_edge(self, angle_threshold_degrees=30): @@ -363,4 +363,4 @@ def is_sharp_edge(self, angle_threshold_degrees=30): # For now, non-smooth edges are considered sharp return True else: - return False # Undefined edges are not considered sharp \ No newline at end of file + return False # Undefined edges are not considered sharp diff --git a/src/pyocc/entity_mapper.py b/src/pyocc/entity_mapper.py index 9121bb9..1987671 100644 --- a/src/pyocc/entity_mapper.py +++ b/src/pyocc/entity_mapper.py @@ -14,9 +14,10 @@ to check if an oriented edge is used by a wire and known to the entity mapper. """ -import numpy as np from collections import defaultdict +import numpy as np + class EntityMapper: """ @@ -53,13 +54,19 @@ def __init__(self, solid=None): self.shape_id_counter = 0 # Property storage - self.vertex_properties = defaultdict(lambda: defaultdict(dict)) # shape_id -> vertex_id -> property_name -> value + self.vertex_properties = defaultdict( + lambda: defaultdict(dict) + ) # shape_id -> vertex_id -> property_name -> value self.edge_properties = defaultdict(lambda: defaultdict(dict)) self.face_properties = defaultdict(lambda: defaultdict(dict)) # Connectivity data - self.vertex_edge_connectivity = defaultdict(lambda: defaultdict(list)) # shape_id -> vertex_id -> [edge_ids] - self.edge_face_connectivity = defaultdict(lambda: defaultdict(list)) # shape_id -> edge_id -> [face_ids] + self.vertex_edge_connectivity = defaultdict( + lambda: defaultdict(list) + ) # shape_id -> vertex_id -> [edge_ids] + self.edge_face_connectivity = defaultdict( + lambda: defaultdict(list) + ) # shape_id -> edge_id -> [face_ids] # Build the index lookup tables if solid is provided if solid is not None: @@ -303,7 +310,9 @@ def set_vertex_properties_batch(self, shape_id, vertex_ids, property_name, value def get_vertex_properties_batch(self, shape_id, vertex_ids, property_name): """Get properties for multiple vertices""" - return [self.get_vertex_property(shape_id, vertex_id, property_name) for vertex_id in vertex_ids] + return [ + self.get_vertex_property(shape_id, vertex_id, property_name) for vertex_id in vertex_ids + ] # Geometric data access def get_vertex_positions(self, shape_id): @@ -360,18 +369,24 @@ def get_edge_faces(self, shape_id, edge_id): def serialize(self): """Serialize mapper state to dictionary""" return { - 'vertex_properties': dict(self.vertex_properties), - 'edge_properties': dict(self.edge_properties), - 'face_properties': dict(self.face_properties), - 'shape_id_counter': self.shape_id_counter + "vertex_properties": dict(self.vertex_properties), + "edge_properties": dict(self.edge_properties), + "face_properties": dict(self.face_properties), + "shape_id_counter": self.shape_id_counter, } def deserialize(self, data): """Deserialize mapper state from dictionary""" - self.vertex_properties = defaultdict(lambda: defaultdict(dict), data.get('vertex_properties', {})) - self.edge_properties = defaultdict(lambda: defaultdict(dict), data.get('edge_properties', {})) - self.face_properties = defaultdict(lambda: defaultdict(dict), data.get('face_properties', {})) - self.shape_id_counter = data.get('shape_id_counter', 0) + self.vertex_properties = defaultdict( + lambda: defaultdict(dict), data.get("vertex_properties", {}) + ) + self.edge_properties = defaultdict( + lambda: defaultdict(dict), data.get("edge_properties", {}) + ) + self.face_properties = defaultdict( + lambda: defaultdict(dict), data.get("face_properties", {}) + ) + self.shape_id_counter = data.get("shape_id_counter", 0) # Alternative naming for serialization methods def to_dict(self): @@ -388,13 +403,17 @@ def _get_hash(self, ent): """Get hash value for an entity based on geometric properties""" try: # For vertices, use geometric coordinates - if hasattr(ent, 'point'): + if hasattr(ent, "point"): coords = ent.point() rounded_coords = tuple(np.round(coords, decimals=12)) return hash(rounded_coords) # For edges, use start and end points plus length - elif hasattr(ent, 'start_vertex') and hasattr(ent, 'end_vertex') and hasattr(ent, 'length'): + elif ( + hasattr(ent, "start_vertex") + and hasattr(ent, "end_vertex") + and hasattr(ent, "length") + ): try: start_pt = ent.start_vertex().point() end_pt = ent.end_vertex().point() @@ -415,11 +434,16 @@ def _get_hash(self, ent): midpoint_rounded = tuple(np.round(midpoint, decimals=12)) # Create a tuple combining all geometric properties to ensure uniqueness - edge_properties = (start_rounded, end_rounded, direction_rounded, - midpoint_rounded, length_rounded) + edge_properties = ( + start_rounded, + end_rounded, + direction_rounded, + midpoint_rounded, + length_rounded, + ) return hash(edge_properties) - except Exception as e: + except Exception: # If geometric methods fail, use the actual underlying OCC shape's address # This should be more consistent than Python wrapper object addresses try: @@ -427,11 +451,11 @@ def _get_hash(self, ent): # Use the TShape's address which should be consistent for the same geometric shape tshape_addr = occ_shape.TShape().__repr__() return hash(tshape_addr) - except: + except Exception: return hash(str(ent)) # For faces, use center of mass and area - elif hasattr(ent, 'center_of_mass') and hasattr(ent, 'area'): + elif hasattr(ent, "center_of_mass") and hasattr(ent, "area"): try: center = ent.center_of_mass() area = ent.area() @@ -440,13 +464,13 @@ def _get_hash(self, ent): area_rounded = round(area, 12) return hash((center_rounded, area_rounded)) - except: + except Exception: # Fallback for faces that can't compute center/area try: occ_shape = ent.topods_shape() tshape_addr = occ_shape.TShape().__repr__() return hash(tshape_addr) - except: + except Exception: return hash(str(ent)) # Fallback: use TShape address for consistency @@ -454,7 +478,7 @@ def _get_hash(self, ent): occ_shape = ent.topods_shape() tshape_addr = occ_shape.TShape().__repr__() return hash(tshape_addr) - except: + except Exception: return hash(str(ent)) except (AttributeError, Exception): @@ -549,25 +573,27 @@ def _build_connectivity(self, solid, shape_id): for edge_idx, edge in enumerate(edges): try: - if (edge.start_vertex().point() == vertex.point()).all() or \ - (edge.end_vertex().point() == vertex.point()).all(): + if (edge.start_vertex().point() == vertex.point()).all() or ( + edge.end_vertex().point() == vertex.point() + ).all(): edge_id = self.edge_index(edge) connected_edges.append(edge_id) - except: + except Exception: # Fallback comparison start_point = edge.start_vertex().point() end_point = edge.end_vertex().point() vertex_point = vertex.point() - if (np.allclose(start_point, vertex_point) or - np.allclose(end_point, vertex_point)): + if np.allclose(start_point, vertex_point) or np.allclose( + end_point, vertex_point + ): edge_id = self.edge_index(edge) connected_edges.append(edge_id) self.vertex_edge_connectivity[shape_id][vertex_id] = connected_edges # Build edge-face connectivity using the mixins - if hasattr(solid, 'faces_from_edge'): + if hasattr(solid, "faces_from_edge"): for edge_idx, edge in enumerate(edges): edge_id = self.edge_index(edge) adjacent_faces = [] @@ -577,7 +603,7 @@ def _build_connectivity(self, solid, shape_id): for face in edge_faces: face_id = self.face_index(face) adjacent_faces.append(face_id) - except: + except Exception: # Fallback: find faces manually for face_idx, face in enumerate(faces): try: @@ -588,7 +614,7 @@ def _build_connectivity(self, solid, shape_id): if face_id not in adjacent_faces: adjacent_faces.append(face_id) break - except: + except Exception: pass - self.edge_face_connectivity[shape_id][edge_id] = adjacent_faces \ No newline at end of file + self.edge_face_connectivity[shape_id][edge_id] = adjacent_faces diff --git a/src/pyocc/face.py b/src/pyocc/face.py index 9dbf8da..a2d8cf8 100644 --- a/src/pyocc/face.py +++ b/src/pyocc/face.py @@ -3,35 +3,50 @@ This module provides the Face class which represents a surface bounded by wires. """ -import numpy as np + import logging -from OCC.Core.TopoDS import TopoDS_Face -from OCC.Core.BRep import BRep_Tool -from OCC.Core.BRepTools import breptools + +import numpy as np from OCC.Core.BRepAdaptor import BRepAdaptor_Surface -from OCC.Core.GeomLProp import GeomLProp_SLProps -from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnSurf -from OCC.Core.GProp import GProp_GProps from OCC.Core.BRepGProp import brepgprop from OCC.Core.GeomAbs import ( - GeomAbs_Plane, GeomAbs_Cylinder, GeomAbs_Cone, GeomAbs_Sphere, - GeomAbs_Torus, GeomAbs_BezierSurface, GeomAbs_BSplineSurface, - GeomAbs_SurfaceOfRevolution, GeomAbs_SurfaceOfExtrusion, - GeomAbs_OffsetSurface, GeomAbs_OtherSurface + GeomAbs_BezierSurface, + GeomAbs_BSplineSurface, + GeomAbs_Cone, + GeomAbs_Cylinder, + GeomAbs_OffsetSurface, + GeomAbs_OtherSurface, + GeomAbs_Plane, + GeomAbs_Sphere, + GeomAbs_SurfaceOfExtrusion, + GeomAbs_SurfaceOfRevolution, + GeomAbs_Torus, ) -from OCC.Core.gp import gp_Dir, gp_Pnt, gp_Vec, gp_Pnt2d -from OCC.Core.TopAbs import TopAbs_REVERSED, TopAbs_FORWARD +from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnSurf +from OCC.Core.GeomLProp import GeomLProp_SLProps +from OCC.Core.gp import gp_Dir, gp_Pnt +from OCC.Core.GProp import GProp_GProps +from OCC.Core.TopoDS import TopoDS_Face from .base import ( - EdgeContainerMixin, VertexContainerMixin, WireContainerMixin, - SurfacePropertiesMixin, BoundingBoxMixin + BoundingBoxMixin, + EdgeContainerMixin, + SurfacePropertiesMixin, + VertexContainerMixin, + WireContainerMixin, ) -from .shape import Shape from .geometry.box import Box -from .geometry.interval import Interval +from .shape import Shape -class Face(Shape, EdgeContainerMixin, VertexContainerMixin, WireContainerMixin, SurfacePropertiesMixin, BoundingBoxMixin): +class Face( + Shape, + EdgeContainerMixin, + VertexContainerMixin, + WireContainerMixin, + SurfacePropertiesMixin, + BoundingBoxMixin, +): """ A face representing a bounded surface. @@ -72,15 +87,9 @@ def make_from_wires(wires): return None from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace - from OCC.Core.BRepLib import breplib from OCC.Core.gp import gp_Pln, gp_Pnt - from OCC.Core.GeomLib import GeomLib_IsPlanarSurface - from OCC.Core.BRepAdaptor import BRepAdaptor_CompCurve - from OCC.Core.GeomPlate import GeomPlate_MakeApprox, GeomPlate_BuildPlateSurface - from OCC.Core.GeomPlate import GeomPlate_PointConstraint - from OCC.Core.Geom import Geom_BSplineSurface - from OCC.Core.ShapeAnalysis import ShapeAnalysis_Wire from OCC.Core.Precision import precision + from OCC.Core.ShapeAnalysis import ShapeAnalysis_Wire try: # First validate all wires @@ -178,8 +187,8 @@ def make_from_wires(wires): best_triangle = None for i in range(len(sample_points)): - for j in range(i+1, len(sample_points)): - for k in range(j+1, len(sample_points)): + for j in range(i + 1, len(sample_points)): + for k in range(j + 1, len(sample_points)): p1, p2, p3 = sample_points[i], sample_points[j], sample_points[k] v1 = np.array(p2) - np.array(p1) @@ -325,10 +334,7 @@ def uv_bounds(self): vmin = adaptor.FirstVParameter() vmax = adaptor.LastVParameter() - return Box( - np.array([umin, vmin, 0]), - np.array([umax, vmax, 0]) - ) + return Box(np.array([umin, vmin]), np.array([umax, vmax])) def point(self, *args): """ @@ -368,9 +374,7 @@ def tangent(self, uv): Tuple of (du, dv) where each is a numpy array [dx, dy, dz] """ # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), uv[0], uv[1], 1, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), uv[0], uv[1], 1, 1e-7) if not props.IsTangentUDefined() or not props.IsTangentVDefined(): return np.array([0.0, 0.0, 0.0]), np.array([0.0, 0.0, 0.0]) @@ -378,10 +382,7 @@ def tangent(self, uv): du = props.D1U() dv = props.D1V() - return ( - np.array([du.X(), du.Y(), du.Z()]), - np.array([dv.X(), dv.Y(), dv.Z()]) - ) + return (np.array([du.X(), du.Y(), du.Z()]), np.array([dv.X(), dv.Y(), dv.Z()])) def normal(self, *args): """ @@ -407,9 +408,7 @@ def normal(self, *args): raise ValueError("Expected either uv array or separate u,v parameters") # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), u, v, 1, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), u, v, 1, 1e-7) if not props.IsNormalDefined(): return np.array([0.0, 0.0, 0.0]) @@ -446,9 +445,7 @@ def mean_curvature(self, *args): raise ValueError("Expected either uv array or separate u,v parameters") # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), u, v, 2, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), u, v, 2, 1e-7) if not props.IsCurvatureDefined(): return 0.0 @@ -479,9 +476,7 @@ def gaussian_curvature(self, *args): raise ValueError("Expected either uv array or separate u,v parameters") # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), u, v, 2, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), u, v, 2, 1e-7) if not props.IsCurvatureDefined(): return 0.0 @@ -499,9 +494,7 @@ def min_curvature(self, uv): Minimum curvature value """ # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), uv[0], uv[1], 2, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), uv[0], uv[1], 2, 1e-7) if not props.IsCurvatureDefined(): return 0.0 @@ -519,9 +512,7 @@ def max_curvature(self, uv): Maximum curvature value """ # Create a surface properties object - props = GeomLProp_SLProps( - self.surface(), uv[0], uv[1], 2, 1e-7 - ) + props = GeomLProp_SLProps(self.surface(), uv[0], uv[1], 2, 1e-7) if not props.IsCurvatureDefined(): return 0.0 @@ -584,7 +575,7 @@ def surface_type(self): GeomAbs_SurfaceOfRevolution: "revolution", GeomAbs_SurfaceOfExtrusion: "extrusion", GeomAbs_OffsetSurface: "offset", - GeomAbs_OtherSurface: "other" + GeomAbs_OtherSurface: "other", } return type_map.get(surface_type, "unknown") @@ -637,13 +628,12 @@ def is_left_of(self, edge): True if the face is to the left of the edge """ try: - from OCC.Core.BRepTools import BRepTools_WireExplorer - from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopAbs import TopAbs_EDGE + from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopoDS import topods # Get the edge's TopoDS_Edge - edge_shape = edge.topods_edge() if hasattr(edge, 'topods_edge') else edge._shape + edge_shape = edge.topods_edge() if hasattr(edge, "topods_edge") else edge._shape # Find if this edge belongs to any wire of this face face_shape = self.topods_face() @@ -687,6 +677,7 @@ def is_left_of(self, edge): # Check the edge orientation within the face # If the edge is reversed in the face, we need to flip our calculation from OCC.Core.TopAbs import TopAbs_REVERSED + if edge_orientation_in_face == TopAbs_REVERSED: left_direction = -left_direction @@ -749,7 +740,9 @@ def find_closest_point_data(self, datum): vertex = BRepBuilderAPI_MakeVertex(pt).Shape() # Use BRepExtrema to find the closest point on the face - distance_calculator = BRepExtrema_DistShapeShape(vertex, self.topods_shape(), Extrema_ExtFlag_MIN) + distance_calculator = BRepExtrema_DistShapeShape( + vertex, self.topods_shape(), Extrema_ExtFlag_MIN + ) distance_calculator.Perform() if not distance_calculator.IsDone() or distance_calculator.NbSolution() < 1: @@ -776,7 +769,7 @@ def find_closest_point_data(self, datum): # Fallback to center UV bounds = self.uv_bounds() uv_param = bounds.center()[:2] - except: + except Exception: # Fallback to center UV bounds = self.uv_bounds() uv_param = bounds.center()[:2] @@ -869,7 +862,7 @@ def normal_at(self, *args): u, v = args else: raise ValueError("Expected either uv array or separate u,v parameters") - + return self.normal(u, v) def consistent_normal_direction(self): @@ -885,7 +878,7 @@ def consistent_normal_direction(self): """ try: # Get the face orientation - face_orientation = self.topods_face().Orientation() + self.topods_face().Orientation() # Sample a few points on the surface to check normal consistency bounds = self.uv_bounds() @@ -936,15 +929,14 @@ def project_point(self, point): return ( np.array([closest_pnt.X(), closest_pnt.Y(), closest_pnt.Z()]), np.array([u, v]), - distance + distance, ) else: # Fallback - use center of parameter space bounds = self.uv_bounds() - center_uv = np.array([ - (bounds.min[0] + bounds.max[0]) / 2, - (bounds.min[1] + bounds.max[1]) / 2 - ]) + center_uv = np.array( + [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2] + ) center_point = np.array(self.point(center_uv)) distance = np.linalg.norm(np.array(point) - center_point) @@ -956,19 +948,19 @@ def __str__(self): surf_type = self.surface_type() area = self.area() return f"Face({surf_type}, area={area:.3f})" - except: + except Exception: return "Face(invalid)" def surface_parameters(self) -> dict: """ Get detailed surface parameters based on surface type. - + Returns: Dictionary containing surface-specific parameters """ surface_type = self.surface_type() params = {"surface_type": surface_type} - + try: if surface_type == "cylinder": params.update(self._get_cylinder_parameters()) @@ -983,7 +975,7 @@ def surface_parameters(self) -> dict: except Exception as e: # Log error but don't fail - return basic params params["error"] = str(e) - + return params def _get_cylinder_parameters(self) -> dict: @@ -991,20 +983,23 @@ def _get_cylinder_parameters(self) -> dict: try: from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Cylinder - from OCC.Core.gp import gp_Cylinder - + adaptor = BRepAdaptor_Surface(self.topods_face()) if adaptor.GetType() != GeomAbs_Cylinder: raise ValueError("Surface is not a cylinder") - + cylinder = adaptor.Cylinder() axis = cylinder.Axis() - + return { "axis_origin": [axis.Location().X(), axis.Location().Y(), axis.Location().Z()], - "axis_direction": [axis.Direction().X(), axis.Direction().Y(), axis.Direction().Z()], + "axis_direction": [ + axis.Direction().X(), + axis.Direction().Y(), + axis.Direction().Z(), + ], "radius": cylinder.Radius(), - "height": self._estimate_cylinder_height() + "height": self._estimate_cylinder_height(), } except Exception as e: raise ValueError(f"Failed to extract cylinder parameters: {e}") @@ -1014,20 +1009,23 @@ def _get_torus_parameters(self) -> dict: try: from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Torus - from OCC.Core.gp import gp_Torus - + adaptor = BRepAdaptor_Surface(self.topods_face()) if adaptor.GetType() != GeomAbs_Torus: raise ValueError("Surface is not a torus") - + torus = adaptor.Torus() axis = torus.Axis() - + return { "axis_origin": [axis.Location().X(), axis.Location().Y(), axis.Location().Z()], - "axis_direction": [axis.Direction().X(), axis.Direction().Y(), axis.Direction().Z()], + "axis_direction": [ + axis.Direction().X(), + axis.Direction().Y(), + axis.Direction().Z(), + ], "major_radius": torus.MajorRadius(), - "minor_radius": torus.MinorRadius() + "minor_radius": torus.MinorRadius(), } except Exception as e: raise ValueError(f"Failed to extract torus parameters: {e}") @@ -1037,19 +1035,15 @@ def _get_sphere_parameters(self) -> dict: try: from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Sphere - from OCC.Core.gp import gp_Sphere - + adaptor = BRepAdaptor_Surface(self.topods_face()) if adaptor.GetType() != GeomAbs_Sphere: raise ValueError("Surface is not a sphere") - + sphere = adaptor.Sphere() center = sphere.Location() - - return { - "center": [center.X(), center.Y(), center.Z()], - "radius": sphere.Radius() - } + + return {"center": [center.X(), center.Y(), center.Z()], "radius": sphere.Radius()} except Exception as e: raise ValueError(f"Failed to extract sphere parameters: {e}") @@ -1058,20 +1052,23 @@ def _get_cone_parameters(self) -> dict: try: from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Cone - from OCC.Core.gp import gp_Cone - + adaptor = BRepAdaptor_Surface(self.topods_face()) if adaptor.GetType() != GeomAbs_Cone: raise ValueError("Surface is not a cone") - + cone = adaptor.Cone() axis = cone.Axis() - + return { "axis_origin": [axis.Location().X(), axis.Location().Y(), axis.Location().Z()], - "axis_direction": [axis.Direction().X(), axis.Direction().Y(), axis.Direction().Z()], + "axis_direction": [ + axis.Direction().X(), + axis.Direction().Y(), + axis.Direction().Z(), + ], "radius1": cone.RefRadius(), - "semi_angle": cone.SemiAngle() + "semi_angle": cone.SemiAngle(), } except Exception as e: raise ValueError(f"Failed to extract cone parameters: {e}") @@ -1081,18 +1078,17 @@ def _get_plane_parameters(self) -> dict: try: from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.GeomAbs import GeomAbs_Plane - from OCC.Core.gp import gp_Pln - + adaptor = BRepAdaptor_Surface(self.topods_face()) if adaptor.GetType() != GeomAbs_Plane: raise ValueError("Surface is not a plane") - + plane = adaptor.Plane() axis = plane.Axis() - + return { "origin": [axis.Location().X(), axis.Location().Y(), axis.Location().Z()], - "normal": [axis.Direction().X(), axis.Direction().Y(), axis.Direction().Z()] + "normal": [axis.Direction().X(), axis.Direction().Y(), axis.Direction().Z()], } except Exception as e: raise ValueError(f"Failed to extract plane parameters: {e}") @@ -1107,4 +1103,4 @@ def _estimate_cylinder_height(self) -> float: return max(dimensions) return 1.0 # Default height except Exception: - return 1.0 \ No newline at end of file + return 1.0 diff --git a/src/pyocc/geometry/__init__.py b/src/pyocc/geometry/__init__.py index 1a2390c..a8e823d 100644 --- a/src/pyocc/geometry/__init__.py +++ b/src/pyocc/geometry/__init__.py @@ -5,27 +5,33 @@ curves, surfaces, intervals, triangular meshes, and other geometric data. """ -from .interval import Interval -from .box import Box +from . import geom_utils, tri_utils from .arc_length_param_finder import ArcLengthParamFinder -from . import tri_utils -from . import geom_utils +from .box import Box +from .interval import Interval from .tri_utils import ( - write_obj, read_obj, write_ply, - compute_triangle_normals, compute_vertex_normals, - compute_triangle_areas, validate_mesh, remove_duplicate_vertices + compute_triangle_areas, + compute_triangle_normals, + compute_vertex_normals, + read_obj, + remove_duplicate_vertices, + validate_mesh, + write_obj, + write_ply, ) __all__ = [ - 'Interval', - 'Box', - 'ArcLengthParamFinder', - 'write_obj', - 'read_obj', - 'write_ply', - 'compute_triangle_normals', - 'compute_vertex_normals', - 'compute_triangle_areas', - 'validate_mesh', - 'remove_duplicate_vertices' -] \ No newline at end of file + "Interval", + "Box", + "ArcLengthParamFinder", + "tri_utils", + "geom_utils", + "write_obj", + "read_obj", + "write_ply", + "compute_triangle_normals", + "compute_vertex_normals", + "compute_triangle_areas", + "validate_mesh", + "remove_duplicate_vertices", +] diff --git a/src/pyocc/geometry/arc_length_param_finder.py b/src/pyocc/geometry/arc_length_param_finder.py index f2f1bff..109637b 100644 --- a/src/pyocc/geometry/arc_length_param_finder.py +++ b/src/pyocc/geometry/arc_length_param_finder.py @@ -5,10 +5,10 @@ that provide equal arc-length spacing, which is useful for uniform sampling along geometrically complex curves. """ -import numpy as np -from typing import List, Optional, Union -from .interval import Interval +from typing import List + +import numpy as np class ArcLengthParamFinder: @@ -20,7 +20,15 @@ class ArcLengthParamFinder: spacing along the curve. """ - def __init__(self, edge_or_points=None, us=None, edge=None, points=None, num_arc_length_samples=100, tolerance=1e-6): + def __init__( + self, + edge_or_points=None, + us=None, + edge=None, + points=None, + num_arc_length_samples=1000, + tolerance=1e-6, + ): """ Create a class to generate arc-length parameters. @@ -34,7 +42,7 @@ def __init__(self, edge_or_points=None, us=None, edge=None, points=None, num_arc """ # Handle backward compatibility: if first argument is provided if edge_or_points is not None: - if hasattr(edge_or_points, 'point'): # Looks like an Edge object + if hasattr(edge_or_points, "point"): # Looks like an Edge object edge = edge_or_points else: # Assume it's points points = edge_or_points @@ -50,7 +58,9 @@ def __init__(self, edge_or_points=None, us=None, edge=None, points=None, num_arc else: # This code could be expanded to sample from surface iso-parametric lines if points is None or us is None: - raise ValueError("Either edge must be provided, or both points and us must be provided") + raise ValueError( + "Either edge must be provided, or both points and us must be provided" + ) self.points = np.array(points) if not isinstance(points, np.ndarray) else points self.us = np.array(us) if not isinstance(us, np.ndarray) else us @@ -116,7 +126,9 @@ def find_arc_length_parameters(self, num_samples: int) -> List[float]: u_param = self.us[-1] else: # Normal interpolation case - arc_length_index -= 1 # searchsorted gives us the insertion point, we want the left boundary + arc_length_index -= ( + 1 # searchsorted gives us the insertion point, we want the left boundary + ) u_low = self.us[arc_length_index] u_high = self.us[arc_length_index + 1] @@ -130,7 +142,9 @@ def find_arc_length_parameters(self, num_samples: int) -> List[float]: else: position_in_interval = (desired_arc_length_fraction - frac_low) / d_frac position_in_interval = np.clip(position_in_interval, 0.0, 1.0) # Clamp to [0,1] - u_param = u_low + position_in_interval * (u_high - u_low) # Direct interpolation (faster) + u_param = u_low + position_in_interval * ( + u_high - u_low + ) # Direct interpolation (faster) arc_length_params.append(u_param) @@ -160,7 +174,7 @@ def _generate_data_from_edge(self, edge, num_points_arclength: int): self.points = np.array(self.points) self.us = np.array(self.us) - except Exception as e: + except Exception: self.good = False self.points = None self.us = None @@ -179,7 +193,7 @@ def _check_non_decreasing(self, us: List[float]) -> bool: return True for i in range(1, len(us)): - if us[i] < us[i-1]: + if us[i] < us[i - 1]: return False return True @@ -247,7 +261,7 @@ def parameter_to_arc_length(self, parameter: float) -> float: # Calculate arc length up to idx-1 if idx > 1: - point_diffs = self.points[1:idx] - self.points[:idx-1] + point_diffs = self.points[1:idx] - self.points[: idx - 1] lengths = np.linalg.norm(point_diffs, axis=1) arc_length_to_segment = np.sum(lengths) else: diff --git a/src/pyocc/geometry/box.py b/src/pyocc/geometry/box.py index 17a4bda..c92a7d7 100644 --- a/src/pyocc/geometry/box.py +++ b/src/pyocc/geometry/box.py @@ -1,8 +1,9 @@ """ Axis-aligned bounding box implementation. """ -import math + import numpy as np + from .interval import Interval @@ -22,8 +23,8 @@ def __init__(self, min_point=None, max_point=None): """ if min_point is None: # Default to an invalid box if no points provided - self._min = np.array([float('inf'), float('inf'), float('inf')]) - self._max = np.array([float('-inf'), float('-inf'), float('-inf')]) + self._min = np.array([float("inf"), float("inf"), float("inf")]) + self._max = np.array([float("-inf"), float("-inf"), float("-inf")]) else: # Use provided points, making sure min_point <= max_point componentwise self._min = np.minimum(min_point, max_point) @@ -79,6 +80,16 @@ def max(self): """Get the maximum point of the box.""" return self._max + def __iter__(self): + """Iterate the per-axis bounds interleaved as min_0, max_0, min_1, max_1, ... + + This lets a 2D parameter box (e.g. a face's UV bounds) be unpacked as + ``u_min, u_max, v_min, v_max``. + """ + for lo, hi in zip(self._min, self._max): + yield lo + yield hi + def center(self): """Get the center point of the box.""" return 0.5 * (self._min + self._max) @@ -207,8 +218,7 @@ def contains_point(self, point, tol=1e-8): return False pt = np.array(point) - return (np.all(pt >= self._min - tol) and - np.all(pt <= self._max + tol)) + return np.all(pt >= self._min - tol) and np.all(pt <= self._max + tol) def contains_box(self, box, tol=1e-8): """ @@ -224,8 +234,7 @@ def contains_box(self, box, tol=1e-8): if not self.is_valid() or not box.is_valid(): return False - return (np.all(box.min >= self._min - tol) and - np.all(box.max <= self._max + tol)) + return np.all(box.min >= self._min - tol) and np.all(box.max <= self._max + tol) def intersects(self, box, tol=1e-8): """ @@ -241,8 +250,7 @@ def intersects(self, box, tol=1e-8): if not self.is_valid() or not box.is_valid(): return False - return (np.all(self._min <= box.max + tol) and - np.all(self._max + tol >= box.min)) + return np.all(self._min <= box.max + tol) and np.all(self._max + tol >= box.min) def union(self, box): """ @@ -259,10 +267,7 @@ def union(self, box): elif not box.is_valid(): return Box(self._min.copy(), self._max.copy()) - return Box( - np.minimum(self._min, box.min), - np.maximum(self._max, box.max) - ) + return Box(np.minimum(self._min, box.min), np.maximum(self._max, box.max)) def intersection(self, box): """ @@ -332,7 +337,7 @@ def distance_to_point(self, point): The minimum distance (0 if the point is inside) """ if not self.is_valid(): - return float('inf') + return float("inf") # First check if the point is inside the box pt = np.array(point) @@ -346,37 +351,37 @@ def distance_to_point(self, point): def min_x(self): """Get the minimum x-coordinate of the box.""" if not self.is_valid(): - return float('inf') + return float("inf") return self._min[0] def max_x(self): """Get the maximum x-coordinate of the box.""" if not self.is_valid(): - return float('-inf') + return float("-inf") return self._max[0] def min_y(self): """Get the minimum y-coordinate of the box.""" if not self.is_valid(): - return float('inf') + return float("inf") return self._min[1] def max_y(self): """Get the maximum y-coordinate of the box.""" if not self.is_valid(): - return float('-inf') + return float("-inf") return self._max[1] def min_z(self): """Get the minimum z-coordinate of the box.""" if not self.is_valid(): - return float('inf') + return float("inf") return self._min[2] def max_z(self): """Get the maximum z-coordinate of the box.""" if not self.is_valid(): - return float('-inf') + return float("-inf") return self._max[2] def width(self): @@ -414,5 +419,4 @@ def __eq__(self, other): if not self.is_valid() or not other.is_valid(): return False - return (np.allclose(self._min, other.min) and - np.allclose(self._max, other.max)) + return np.allclose(self._min, other.min) and np.allclose(self._max, other.max) diff --git a/src/pyocc/geometry/geom_utils.py b/src/pyocc/geometry/geom_utils.py index 40802de..b96b045 100644 --- a/src/pyocc/geometry/geom_utils.py +++ b/src/pyocc/geometry/geom_utils.py @@ -5,11 +5,12 @@ geometric representations, especially between NumPy arrays and OpenCASCADE geometric types. """ -import numpy as np -from typing import Union, Tuple, Any -from OCC.Core.gp import gp_Pnt, gp_Pnt2d, gp_Vec, gp_Dir, gp_Ax1, gp_Trsf +from typing import Any, Union + +import numpy as np from OCC.Core.Bnd import Bnd_Box +from OCC.Core.gp import gp_Dir, gp_Pnt, gp_Pnt2d, gp_Trsf, gp_Vec from .box import Box @@ -144,7 +145,7 @@ def to_numpy(any_2d_or_3d_type: Any, dtype=np.float32) -> np.ndarray: mat = np.eye(4, dtype=dtype) for i in range(3): for j in range(4): - mat[i, j] = any_2d_or_3d_type.Value(i+1, j+1) + mat[i, j] = any_2d_or_3d_type.Value(i + 1, j + 1) return mat elif isinstance(any_2d_or_3d_type, np.ndarray): return any_2d_or_3d_type.astype(dtype) @@ -195,11 +196,7 @@ def to_gp_dir(any_3d_type: Union[tuple, list, np.ndarray]) -> gp_Dir: if len(any_3d_type) != 3: raise ValueError("Direction must have exactly 3 coordinates") - return gp_Dir( - float(any_3d_type[0]), - float(any_3d_type[1]), - float(any_3d_type[2]) - ) + return gp_Dir(float(any_3d_type[0]), float(any_3d_type[1]), float(any_3d_type[2])) def to_gp_vec(any_3d_type: Union[tuple, list, np.ndarray]) -> gp_Vec: @@ -218,11 +215,7 @@ def to_gp_vec(any_3d_type: Union[tuple, list, np.ndarray]) -> gp_Vec: if len(any_3d_type) != 3: raise ValueError("Vector must have exactly 3 coordinates") - return gp_Vec( - float(any_3d_type[0]), - float(any_3d_type[1]), - float(any_3d_type[2]) - ) + return gp_Vec(float(any_3d_type[0]), float(any_3d_type[1]), float(any_3d_type[2])) def normalize_vector(vec: np.ndarray) -> np.ndarray: @@ -291,26 +284,27 @@ def angle_between_vectors(v1: np.ndarray, v2: np.ndarray) -> float: def vector_angle(v1: np.ndarray, v2: np.ndarray) -> float: """ Calculate angle between two vectors in radians. - + This function computes the smallest angle between two vectors. - + Args: v1 (np.ndarray): First vector v2 (np.ndarray): Second vector - + Returns: float: Angle in radians [0, π] """ v1_norm = normalize_vector(v1) v2_norm = normalize_vector(v2) - + # Clamp dot product to handle numerical errors dot = np.clip(np.dot(v1_norm, v2_norm), -1.0, 1.0) return np.arccos(dot) -def project_point_to_line(point: np.ndarray, line_start: np.ndarray, - line_end: np.ndarray) -> np.ndarray: +def project_point_to_line( + point: np.ndarray, line_start: np.ndarray, line_end: np.ndarray +) -> np.ndarray: """ Project a point onto a line. @@ -324,21 +318,22 @@ def project_point_to_line(point: np.ndarray, line_start: np.ndarray, """ # Normalize the line direction vector line_dir_normalized = normalize_vector(line_end - line_start) - + # Vector from line_point to point vec_to_point = point - line_start - + # Project vec_to_point onto the line direction projection = np.dot(vec_to_point, line_dir_normalized) - + # Calculate the closest point on the line projected_point = line_start + projection * line_dir_normalized - + return projected_point -def point_line_distance(point: np.ndarray, line_point: np.ndarray, - line_direction: np.ndarray) -> float: +def point_line_distance( + point: np.ndarray, line_point: np.ndarray, line_direction: np.ndarray +) -> float: """ Calculate the shortest distance from a point to a line. @@ -352,22 +347,23 @@ def point_line_distance(point: np.ndarray, line_point: np.ndarray, """ # Normalize the line direction vector line_dir_normalized = normalize_vector(line_direction) - + # Vector from line_point to point vec_to_point = point - line_point - + # Project vec_to_point onto the line direction projection = np.dot(vec_to_point, line_dir_normalized) - + # Calculate the closest point on the line closest_point = line_point + projection * line_dir_normalized - + # Return the distance between the original point and the closest point on the line return np.linalg.norm(point - closest_point) -def point_plane_distance(point: np.ndarray, plane_point: np.ndarray, - plane_normal: np.ndarray) -> float: +def point_plane_distance( + point: np.ndarray, plane_point: np.ndarray, plane_normal: np.ndarray +) -> float: """ Calculate the shortest distance from a point to a plane. @@ -381,17 +377,18 @@ def point_plane_distance(point: np.ndarray, plane_point: np.ndarray, """ # Normalize the plane normal vector normal = normalize_vector(plane_normal) - + # Vector from plane_point to point vec_to_point = point - plane_point - + # The distance is the absolute value of the dot product # between the normalized normal and the vector to the point return abs(np.dot(normal, vec_to_point)) -def project_point_to_plane(point: np.ndarray, plane_point: np.ndarray, - plane_normal: np.ndarray) -> np.ndarray: +def project_point_to_plane( + point: np.ndarray, plane_point: np.ndarray, plane_normal: np.ndarray +) -> np.ndarray: """ Project a point onto a plane. @@ -405,21 +402,20 @@ def project_point_to_plane(point: np.ndarray, plane_point: np.ndarray, """ # Normalize the plane normal normal = normalize_vector(plane_normal) - + # Vector from plane_point to point vec_to_point = point - plane_point - + # Distance from point to plane along the normal direction distance = np.dot(vec_to_point, normal) - + # Subtract the normal component to get the projection projected_point = point - distance * normal - + return projected_point -def is_colinear(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray, - tolerance: float = 1e-10) -> bool: +def is_colinear(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray, tolerance: float = 1e-10) -> bool: """ Check if three points are colinear. @@ -461,7 +457,7 @@ def cross_product(v1: np.ndarray, v2: np.ndarray) -> np.ndarray: def compute_triangle_normal(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> np.ndarray: """ Calculate the normal vector of a triangle. - + The normal points in the direction determined by the right-hand rule, moving from p1 to p2 to p3. @@ -476,10 +472,10 @@ def compute_triangle_normal(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> n # Calculate two edges of the triangle edge1 = p2 - p1 edge2 = p3 - p1 - + # Calculate the cross product of the edges normal = np.cross(edge1, edge2) - + # Normalize the normal vector return normalize_vector(normal) @@ -499,15 +495,17 @@ def compute_triangle_area(p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> flo # Calculate two edges of the triangle edge1 = p2 - p1 edge2 = p3 - p1 - + # Calculate the cross product of the edges cross = np.cross(edge1, edge2) - + # The area is half the magnitude of the cross product return np.linalg.norm(cross) / 2.0 -def barycentric_coordinates(p: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray) -> np.ndarray: +def barycentric_coordinates( + p: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray +) -> np.ndarray: """ Calculate barycentric coordinates of a point with respect to a triangle. @@ -524,29 +522,35 @@ def barycentric_coordinates(p: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: n v0 = p2 - p1 v1 = p3 - p1 v2 = p - p1 - + # Compute dot products d00 = np.dot(v0, v0) d01 = np.dot(v0, v1) d11 = np.dot(v1, v1) d20 = np.dot(v2, v0) d21 = np.dot(v2, v1) - + # Compute barycentric coordinates denom = d00 * d11 - d01 * d01 if abs(denom) < 1e-10: # Degenerate triangle return np.array([0.0, 0.0, 0.0]) - + v = (d11 * d20 - d01 * d21) / denom w = (d00 * d21 - d01 * d20) / denom u = 1.0 - v - w - + return np.array([u, v, w]) -def point_in_triangle(p: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndarray, - include_boundary: bool = True, tolerance: float = 1e-10) -> bool: +def point_in_triangle( + p: np.ndarray, + p1: np.ndarray, + p2: np.ndarray, + p3: np.ndarray, + include_boundary: bool = True, + tolerance: float = 1e-10, +) -> bool: """ Check if a point is inside a triangle. @@ -563,7 +567,7 @@ def point_in_triangle(p: np.ndarray, p1: np.ndarray, p2: np.ndarray, p3: np.ndar """ # Calculate barycentric coordinates coords = barycentric_coordinates(p, p1, p2, p3) - + if include_boundary: # Point is inside or on the boundary if all coordinates are >= 0 return bool(np.all(coords >= -tolerance)) @@ -599,21 +603,23 @@ def create_rotation_matrix(axis: np.ndarray, angle: float) -> np.ndarray: """ # Normalize the axis axis = normalize_vector(axis) - + # Using Rodrigues' rotation formula a = np.cos(angle) b = 1.0 - a c = np.sin(angle) - + x, y, z = axis - + # Build the rotation matrix - matrix = np.array([ - [a + b*x*x, b*x*y - c*z, b*x*z + c*y], - [b*x*y + c*z, a + b*y*y, b*y*z - c*x], - [b*x*z - c*y, b*y*z + c*x, a + b*z*z] - ]) - + matrix = np.array( + [ + [a + b * x * x, b * x * y - c * z, b * x * z + c * y], + [b * x * y + c * z, a + b * y * y, b * y * z - c * x], + [b * x * z - c * y, b * y * z + c * x, a + b * z * z], + ] + ) + return matrix @@ -629,15 +635,15 @@ def create_translation_matrix(translation: np.ndarray) -> np.ndarray: """ if len(translation) != 3: raise ValueError("Translation vector must have exactly 3 components") - + # Create identity matrix matrix = np.eye(4) - + # Set translation components in the last column matrix[0, 3] = float(translation[0]) matrix[1, 3] = float(translation[1]) matrix[2, 3] = float(translation[2]) - + return matrix @@ -654,7 +660,7 @@ def create_scale_matrix(scale: Union[float, np.ndarray]) -> np.ndarray: """ # Create identity matrix matrix = np.eye(4) - + # Uniform scaling case if isinstance(scale, (int, float)): matrix[0, 0] = float(scale) @@ -667,7 +673,7 @@ def create_scale_matrix(scale: Union[float, np.ndarray]) -> np.ndarray: matrix[0, 0] = float(scale[0]) matrix[1, 1] = float(scale[1]) matrix[2, 2] = float(scale[2]) - + return matrix @@ -685,58 +691,60 @@ def transform_points(points: np.ndarray, transform_matrix: np.ndarray) -> np.nda # Check dimensions if len(points.shape) != 2: raise ValueError("Points must be a 2D array with shape (N, D)") - + point_dim = points.shape[1] matrix_shape = transform_matrix.shape - + # Handle 3D points with 4x4 homogeneous transformation if point_dim == 3 and matrix_shape == (4, 4): # Convert to homogeneous coordinates (add w=1) homogeneous_points = np.ones((len(points), 4)) homogeneous_points[:, 0:3] = points - + # Apply transformation transformed = np.dot(homogeneous_points, transform_matrix.T) - + # Convert back to Cartesian coordinates (divide by w) w = transformed[:, 3:4] # Keep this as a column vector for broadcasting mask = np.abs(w) > 1e-10 # Avoid division by zero result = np.zeros_like(points) - + # Only divide by w where w is non-zero if np.all(mask): result = transformed[:, 0:3] / w else: result[mask.flatten()] = transformed[mask.flatten(), 0:3] / w[mask] - + return result - + # Handle 2D points with 3x3 homogeneous transformation elif point_dim == 2 and matrix_shape == (3, 3): # Convert to homogeneous coordinates (add w=1) homogeneous_points = np.ones((len(points), 3)) homogeneous_points[:, 0:2] = points - + # Apply transformation transformed = np.dot(homogeneous_points, transform_matrix.T) - + # Convert back to Cartesian coordinates (divide by w) w = transformed[:, 2:3] mask = np.abs(w) > 1e-10 # Avoid division by zero result = np.zeros_like(transformed[:, 0:2]) result[mask.flatten()] = transformed[mask.flatten(), 0:2] / w[mask] return result - + # Handle 3D points with 3x3 rotation/scaling (no translation) elif point_dim == 3 and matrix_shape == (3, 3): return np.dot(points, transform_matrix.T) - + # Handle special case of 3x4 matrix (convert to 4x4) elif matrix_shape == (3, 4): full_matrix = np.eye(4) full_matrix[0:3, :] = transform_matrix return transform_points(points, full_matrix) - + else: - raise ValueError(f"Unsupported combination: points shape {points.shape} with " - f"transformation matrix shape {matrix_shape}") \ No newline at end of file + raise ValueError( + f"Unsupported combination: points shape {points.shape} with " + f"transformation matrix shape {matrix_shape}" + ) diff --git a/src/pyocc/geometry/interval.py b/src/pyocc/geometry/interval.py index 349697c..168cc09 100644 --- a/src/pyocc/geometry/interval.py +++ b/src/pyocc/geometry/interval.py @@ -1,6 +1,7 @@ """ 1D Interval representation used for parameter ranges and geometric bounds. """ + import math @@ -30,8 +31,8 @@ def __init__(self, a=0.0, b=0.0): def invalid(cls): """Returns an invalid interval (represents an uninitialized state).""" invalid_interval = cls() - invalid_interval._a = float('inf') - invalid_interval._b = float('-inf') + invalid_interval._a = float("inf") + invalid_interval._b = float("-inf") return invalid_interval def is_invalid(self): @@ -146,4 +147,4 @@ def __eq__(self, other): """Check if two intervals are equal.""" if not isinstance(other, Interval): return False - return math.isclose(self._a, other.a) and math.isclose(self._b, other.b) \ No newline at end of file + return math.isclose(self._a, other.a) and math.isclose(self._b, other.b) diff --git a/src/pyocc/geometry/mesh_utils.py b/src/pyocc/geometry/mesh_utils.py index 6dc1b29..ae20e03 100644 --- a/src/pyocc/geometry/mesh_utils.py +++ b/src/pyocc/geometry/mesh_utils.py @@ -6,36 +6,35 @@ geometric analysis. """ -import numpy as np -from typing import Tuple, List, Optional, Dict, Any import logging +from typing import Any, Dict, List, Optional, Tuple -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - +import numpy as np from OCC.Core.BRep import BRep_Tool -from OCC.Core.TopExp import TopExp_Explorer -from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_ShapeEnum from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh -from OCC.Core.Poly import Poly_Triangulation -from OCC.Core.gp import gp_Pnt -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex, BRepBuilderAPI_MakeEdge -from OCC.Core.Geom import Geom_CartesianPoint +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE +from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopoDS import topods +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + class MeshExtractionError(Exception): """Exception raised during mesh extraction""" + pass -def extract_mesh_from_shape(shape, - linear_deflection: float = 0.1, - angular_deflection: float = 0.1, - relative: bool = False, - return_normals: bool = False, - merge_vertices: bool = True) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]: +def extract_mesh_from_shape( + shape, + linear_deflection: float = 0.1, + angular_deflection: float = 0.1, + relative: bool = False, + return_normals: bool = False, + merge_vertices: bool = True, +) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]: """ Extract triangular mesh from an OpenCASCADE shape. @@ -131,7 +130,7 @@ def _extract_mesh_data(shape) -> Tuple[List, List, List]: face_indices = [ n1 - 1 + vertex_offset, n2 - 1 + vertex_offset, - n3 - 1 + vertex_offset + n3 - 1 + vertex_offset, ] faces.append(face_indices) @@ -143,7 +142,9 @@ def _extract_mesh_data(shape) -> Tuple[List, List, List]: normal = _compute_triangle_normal(v1, v2, v3) face_normals.append(normal) else: - raise MeshExtractionError("Face has insufficient vertices for normal computation") + raise MeshExtractionError( + "Face has insufficient vertices for normal computation" + ) vertex_offset += triangulation.NbNodes() @@ -152,7 +153,9 @@ def _extract_mesh_data(shape) -> Tuple[List, List, List]: if face_count == 0: raise MeshExtractionError("No faces found in shape") - logger.debug(f"Processed {face_count} faces, extracted {len(vertices)} vertices and {len(faces)} triangles") + logger.debug( + f"Processed {face_count} faces, extracted {len(vertices)} vertices and {len(faces)} triangles" + ) return vertices, faces, face_normals @@ -192,8 +195,9 @@ def _compute_face_normals(vertices: np.ndarray, faces: np.ndarray) -> np.ndarray return np.array(normals, dtype=np.float32) -def _merge_duplicate_vertices(vertices: np.ndarray, faces: np.ndarray, - tolerance: float = 1e-6) -> Tuple[np.ndarray, np.ndarray]: +def _merge_duplicate_vertices( + vertices: np.ndarray, faces: np.ndarray, tolerance: float = 1e-6 +) -> Tuple[np.ndarray, np.ndarray]: """Merge duplicate vertices and update face indices""" # Find unique vertices unique_vertices = [] @@ -226,8 +230,7 @@ def _merge_duplicate_vertices(vertices: np.ndarray, faces: np.ndarray, return merged_vertices, merged_faces -def extract_wireframe_from_shape(shape, - sample_points: int = 50) -> Tuple[np.ndarray, np.ndarray]: +def extract_wireframe_from_shape(shape, sample_points: int = 50) -> Tuple[np.ndarray, np.ndarray]: """ Extract wireframe (edges) from an OpenCASCADE shape. @@ -276,7 +279,6 @@ def extract_wireframe_from_shape(shape, def _sample_edge_points(edge, num_points: int) -> List: """Sample points along an edge""" from OCC.Core.BRepAdaptor import BRepAdaptor_Curve - from OCC.Core.GCPnts import GCPnts_UniformAbscissa # Create curve adaptor curve_adaptor = BRepAdaptor_Curve(edge) @@ -336,22 +338,22 @@ def mesh_statistics(vertices: np.ndarray, faces: np.ndarray) -> Dict[str, Any]: Dictionary with mesh statistics """ stats: Dict[str, Any] = { - 'num_vertices': len(vertices), - 'num_faces': len(faces), - 'num_edges': len(faces) * 3 // 2, # Approximate for closed meshes + "num_vertices": len(vertices), + "num_faces": len(faces), + "num_edges": len(faces) * 3 // 2, # Approximate for closed meshes } # Compute bounding box min_coords = np.min(vertices, axis=0) max_coords = np.max(vertices, axis=0) - stats['bounding_box'] = { - 'min': min_coords.tolist(), - 'max': max_coords.tolist(), - 'size': (max_coords - min_coords).tolist() + stats["bounding_box"] = { + "min": min_coords.tolist(), + "max": max_coords.tolist(), + "size": (max_coords - min_coords).tolist(), } # Compute center - stats['center'] = np.mean(vertices, axis=0).tolist() + stats["center"] = np.mean(vertices, axis=0).tolist() # Compute approximate surface area total_area = 0.0 @@ -368,7 +370,7 @@ def mesh_statistics(vertices: np.ndarray, faces: np.ndarray) -> Dict[str, Any]: area = 0.5 * np.linalg.norm(cross) total_area += area - stats['surface_area'] = total_area + stats["surface_area"] = total_area # Compute edge lengths edge_lengths = [] @@ -380,18 +382,18 @@ def mesh_statistics(vertices: np.ndarray, faces: np.ndarray) -> Dict[str, Any]: edge_lengths.append(length) if edge_lengths: - stats['edge_lengths'] = { - 'min': float(np.min(edge_lengths)), - 'max': float(np.max(edge_lengths)), - 'mean': float(np.mean(edge_lengths)), - 'std': float(np.std(edge_lengths)) + stats["edge_lengths"] = { + "min": float(np.min(edge_lengths)), + "max": float(np.max(edge_lengths)), + "mean": float(np.mean(edge_lengths)), + "std": float(np.std(edge_lengths)), } return stats # Convenience functions for easy integration -def shape_to_mesh(shape, quality='medium', return_normals=False): +def shape_to_mesh(shape, quality="medium", return_normals=False): """ Convert OCC shape to mesh with predefined quality settings. @@ -404,26 +406,28 @@ def shape_to_mesh(shape, quality='medium', return_normals=False): Mesh data as tuple """ quality_settings = { - 'low': {'linear_deflection': 0.5, 'angular_deflection': 0.5}, - 'medium': {'linear_deflection': 0.1, 'angular_deflection': 0.1}, - 'high': {'linear_deflection': 0.05, 'angular_deflection': 0.05}, - 'ultra': {'linear_deflection': 0.01, 'angular_deflection': 0.01} + "low": {"linear_deflection": 0.5, "angular_deflection": 0.5}, + "medium": {"linear_deflection": 0.1, "angular_deflection": 0.1}, + "high": {"linear_deflection": 0.05, "angular_deflection": 0.05}, + "ultra": {"linear_deflection": 0.01, "angular_deflection": 0.01}, } if quality not in quality_settings: - raise ValueError(f"Invalid quality setting: {quality}. Must be one of {list(quality_settings.keys())}") + raise ValueError( + f"Invalid quality setting: {quality}. Must be one of {list(quality_settings.keys())}" + ) settings = quality_settings[quality] return extract_mesh_from_shape( shape, - linear_deflection=settings['linear_deflection'], - angular_deflection=settings['angular_deflection'], - return_normals=return_normals + linear_deflection=settings["linear_deflection"], + angular_deflection=settings["angular_deflection"], + return_normals=return_normals, ) -def shape_to_wireframe(shape, quality='medium'): +def shape_to_wireframe(shape, quality="medium"): """ Convert OCC shape to wireframe with predefined quality settings. @@ -434,16 +438,13 @@ def shape_to_wireframe(shape, quality='medium'): Returns: Wireframe data as tuple """ - quality_settings = { - 'low': 20, - 'medium': 50, - 'high': 100, - 'ultra': 200 - } + quality_settings = {"low": 20, "medium": 50, "high": 100, "ultra": 200} if quality not in quality_settings: - raise ValueError(f"Invalid quality setting: {quality}. Must be one of {list(quality_settings.keys())}") + raise ValueError( + f"Invalid quality setting: {quality}. Must be one of {list(quality_settings.keys())}" + ) sample_points = quality_settings[quality] - return extract_wireframe_from_shape(shape, sample_points=sample_points) \ No newline at end of file + return extract_wireframe_from_shape(shape, sample_points=sample_points) diff --git a/src/pyocc/geometry/tri_utils.py b/src/pyocc/geometry/tri_utils.py index c7d50ca..e7a55f5 100644 --- a/src/pyocc/geometry/tri_utils.py +++ b/src/pyocc/geometry/tri_utils.py @@ -4,9 +4,11 @@ This module provides utility functions for working with triangle meshes, including file I/O operations and basic mesh processing. """ -import numpy as np -from typing import Tuple, List, Union, Optional + from pathlib import Path +from typing import Optional, Tuple, Union + +import numpy as np def write_obj(pathname: Union[str, Path], verts: np.ndarray, tris: np.ndarray) -> None: @@ -108,8 +110,12 @@ def read_obj(pathname: Union[str, Path]) -> Tuple[np.ndarray, np.ndarray]: return vertices, triangles -def write_ply(pathname: Union[str, Path], verts: np.ndarray, tris: np.ndarray, - normals: Optional[np.ndarray] = None) -> None: +def write_ply( + pathname: Union[str, Path], + verts: np.ndarray, + tris: np.ndarray, + normals: Optional[np.ndarray] = None, +) -> None: """ Write a triangle mesh to a PLY file. @@ -348,8 +354,9 @@ def validate_mesh(verts: np.ndarray, tris: np.ndarray) -> bool: return False -def remove_duplicate_vertices(verts: np.ndarray, tris: np.ndarray, - tolerance: float = 1e-8) -> Tuple[np.ndarray, np.ndarray]: +def remove_duplicate_vertices( + verts: np.ndarray, tris: np.ndarray, tolerance: float = 1e-8 +) -> Tuple[np.ndarray, np.ndarray]: """ Remove duplicate vertices from a mesh and update triangle indices. @@ -382,8 +389,10 @@ def remove_duplicate_vertices(verts: np.ndarray, tris: np.ndarray, unique_verts.append(vert.copy()) # Update triangle indices - new_tris = np.array([[vertex_map[tri[0]], vertex_map[tri[1]], vertex_map[tri[2]]] - for tri in tris], dtype=tris.dtype) + new_tris = np.array( + [[vertex_map[tri[0]], vertex_map[tri[1]], vertex_map[tri[2]]] for tri in tris], + dtype=tris.dtype, + ) return np.array(unique_verts, dtype=verts.dtype), new_tris @@ -475,7 +484,9 @@ def point_in_triangle_3d(p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.n return point_in_triangle_2d(p_2d, v0_2d, v1_2d, v2_2d) -def barycentric_coordinates_2d(p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> np.ndarray: +def barycentric_coordinates_2d( + p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray +) -> np.ndarray: """ Compute barycentric coordinates of a point with respect to a triangle in 2D. @@ -489,22 +500,33 @@ def barycentric_coordinates_2d(p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2 np.ndarray: Barycentric coordinates (u, v, w) where u + v + w = 1 """ # Compute area of the triangle - area = 0.5 * (-v1[1] * v2[0] + v0[1] * (-v1[0] + v2[0]) + - v0[0] * (v1[1] - v2[1]) + v1[0] * v2[1]) + area = 0.5 * ( + -v1[1] * v2[0] + v0[1] * (-v1[0] + v2[0]) + v0[0] * (v1[1] - v2[1]) + v1[0] * v2[1] + ) # Handle degenerate triangles if abs(area) < 1e-8: return np.array([np.nan, np.nan, np.nan]) # Compute barycentric coordinates - s = 1/(2*area) * (v0[1]*v2[0] - v0[0]*v2[1] + (v2[1] - v0[1])*p[0] + (v0[0] - v2[0])*p[1]) - t = 1/(2*area) * (v0[0]*v1[1] - v0[1]*v1[0] + (v0[1] - v1[1])*p[0] + (v1[0] - v0[0])*p[1]) + s = ( + 1 + / (2 * area) + * (v0[1] * v2[0] - v0[0] * v2[1] + (v2[1] - v0[1]) * p[0] + (v0[0] - v2[0]) * p[1]) + ) + t = ( + 1 + / (2 * area) + * (v0[0] * v1[1] - v0[1] * v1[0] + (v0[1] - v1[1]) * p[0] + (v1[0] - v0[0]) * p[1]) + ) u = 1 - s - t return np.array([u, s, t]) -def barycentric_coordinates_3d(p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> np.ndarray: +def barycentric_coordinates_3d( + p: np.ndarray, v0: np.ndarray, v1: np.ndarray, v2: np.ndarray +) -> np.ndarray: """ Compute barycentric coordinates of a point with respect to a triangle in 3D space. @@ -570,11 +592,7 @@ def triangle_quality_metrics(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> e1 = v2 - v1 # edge from v1 to v2 e2 = v0 - v2 # edge from v2 to v0 - edge_lengths = np.array([ - np.linalg.norm(e0), - np.linalg.norm(e1), - np.linalg.norm(e2) - ]) + edge_lengths = np.array([np.linalg.norm(e0), np.linalg.norm(e1), np.linalg.norm(e2)]) # Compute area area = compute_triangle_area(v0, v1, v2) @@ -607,12 +625,7 @@ def triangle_quality_metrics(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray) -> angles = np.array([angle_A, angle_B, angle_C]) - return { - 'aspect_ratio': aspect_ratio, - 'area': area, - 'perimeter': perimeter, - 'angles': angles - } + return {"aspect_ratio": aspect_ratio, "area": area, "perimeter": perimeter, "angles": angles} def triangle_mesh_quality_metrics(verts: np.ndarray, tris: np.ndarray) -> dict: @@ -661,7 +674,7 @@ def triangle_mesh_quality_metrics(verts: np.ndarray, tris: np.ndarray) -> dict: max_edge_length, min_edge_length, out=np.full_like(max_edge_length, np.inf), - where=min_edge_length > 1e-8 + where=min_edge_length > 1e-8, ) # Compute angles using the law of cosines @@ -671,39 +684,24 @@ def triangle_mesh_quality_metrics(verts: np.ndarray, tris: np.ndarray) -> dict: # First angle (at v0) # cos(A) = (b² + c² - a²) / (2bc) # where a = |e1|, b = |e2|, c = |e0| - numerator = edge_lengths[:, 1]**2 + edge_lengths[:, 2]**2 - edge_lengths[:, 0]**2 + numerator = edge_lengths[:, 1] ** 2 + edge_lengths[:, 2] ** 2 - edge_lengths[:, 0] ** 2 denominator = 2 * edge_lengths[:, 1] * edge_lengths[:, 2] - cosA = np.divide( - numerator, - denominator, - out=np.ones_like(numerator), - where=denominator > 1e-8 - ) + cosA = np.divide(numerator, denominator, out=np.ones_like(numerator), where=denominator > 1e-8) # Clip to handle numerical errors cosA = np.clip(cosA, -1.0, 1.0) angles[:, 0] = np.arccos(cosA) # Second angle (at v1) - numerator = edge_lengths[:, 0]**2 + edge_lengths[:, 2]**2 - edge_lengths[:, 1]**2 + numerator = edge_lengths[:, 0] ** 2 + edge_lengths[:, 2] ** 2 - edge_lengths[:, 1] ** 2 denominator = 2 * edge_lengths[:, 0] * edge_lengths[:, 2] - cosB = np.divide( - numerator, - denominator, - out=np.ones_like(numerator), - where=denominator > 1e-8 - ) + cosB = np.divide(numerator, denominator, out=np.ones_like(numerator), where=denominator > 1e-8) cosB = np.clip(cosB, -1.0, 1.0) angles[:, 1] = np.arccos(cosB) # Third angle (at v2) - numerator = edge_lengths[:, 0]**2 + edge_lengths[:, 1]**2 - edge_lengths[:, 2]**2 + numerator = edge_lengths[:, 0] ** 2 + edge_lengths[:, 1] ** 2 - edge_lengths[:, 2] ** 2 denominator = 2 * edge_lengths[:, 0] * edge_lengths[:, 1] - cosC = np.divide( - numerator, - denominator, - out=np.ones_like(numerator), - where=denominator > 1e-8 - ) + cosC = np.divide(numerator, denominator, out=np.ones_like(numerator), where=denominator > 1e-8) cosC = np.clip(cosC, -1.0, 1.0) angles[:, 2] = np.arccos(cosC) @@ -719,23 +717,19 @@ def triangle_mesh_quality_metrics(verts: np.ndarray, tris: np.ndarray) -> dict: # Skewness = (max_angle - 60) / 120 # Normalized to [0,1] where 0 is equilateral and 1 is degenerate skewness = np.divide( - np.abs(max_angle - 60.0), - 120.0, - out=np.ones_like(max_angle), - where=max_angle > 0 + np.abs(max_angle - 60.0), 120.0, out=np.ones_like(max_angle), where=max_angle > 0 ) return { - 'aspect_ratio': aspect_ratio, - 'min_angle': min_angle, - 'max_angle': max_angle, - 'area': areas, - 'skewness': skewness + "aspect_ratio": aspect_ratio, + "min_angle": min_angle, + "max_angle": max_angle, + "area": areas, + "skewness": skewness, } -def subdivide_triangle(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, - divisions: int = 1) -> dict: +def subdivide_triangle(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, divisions: int = 1) -> dict: """ Subdivide a triangle into smaller triangles. @@ -764,14 +758,16 @@ def subdivide_triangle(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, vertices = np.array([v0, v1, v2, v01, v12, v20]) # Create triangles (4 smaller triangles) - triangles = np.array([ - [0, 3, 5], # v0, v01, v20 - [1, 4, 3], # v1, v12, v01 - [2, 5, 4], # v2, v20, v12 - [3, 4, 5] # v01, v12, v20 (center triangle) - ]) + triangles = np.array( + [ + [0, 3, 5], # v0, v01, v20 + [1, 4, 3], # v1, v12, v01 + [2, 5, 4], # v2, v20, v12 + [3, 4, 5], # v01, v12, v20 (center triangle) + ] + ) - return {'vertices': vertices, 'triangles': triangles} + return {"vertices": vertices, "triangles": triangles} # For divisions > 1, create a more detailed subdivision else: @@ -788,7 +784,7 @@ def subdivide_triangle(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, # Barycentric coordinates a = i / divisions # Weight of v1 b = j / divisions # Weight of v2 - c = 1.0 - a - b # Weight of v0 + c = 1.0 - a - b # Weight of v0 # Compute the point point = c * v0 + a * v1 + b * v2 @@ -803,21 +799,15 @@ def subdivide_triangle(v0: np.ndarray, v1: np.ndarray, v2: np.ndarray, for i in range(divisions): for j in range(divisions - i): # First triangle - triangles.append([ - index_map[(i, j)], - index_map[(i+1, j)], - index_map[(i, j+1)] - ]) + triangles.append([index_map[(i, j)], index_map[(i + 1, j)], index_map[(i, j + 1)]]) # Second triangle (except at the last row) if j < divisions - i - 1: - triangles.append([ - index_map[(i+1, j)], - index_map[(i+1, j+1)], - index_map[(i, j+1)] - ]) + triangles.append( + [index_map[(i + 1, j)], index_map[(i + 1, j + 1)], index_map[(i, j + 1)]] + ) - return {'vertices': np.array(vertices), 'triangles': np.array(triangles)} + return {"vertices": np.array(vertices), "triangles": np.array(triangles)} def triangle_mesh_adjacency(tris: np.ndarray) -> dict: @@ -852,7 +842,7 @@ def triangle_mesh_adjacency(tris: np.ndarray) -> dict: edges = [ (min(tri[0], tri[1]), max(tri[0], tri[1])), (min(tri[1], tri[2]), max(tri[1], tri[2])), - (min(tri[2], tri[0]), max(tri[2], tri[0])) + (min(tri[2], tri[0]), max(tri[2], tri[0])), ] # For each edge, add this triangle to its list @@ -878,7 +868,7 @@ def triangle_mesh_adjacency(tris: np.ndarray) -> dict: # We'll still build the adjacency information in this case elif len(tri_indices) > 2: for i in range(len(tri_indices)): - for j in range(i+1, len(tri_indices)): + for j in range(i + 1, len(tri_indices)): t1, t2 = tri_indices[i], tri_indices[j] adjacency_dict[t1].append(t2) adjacency_dict[t2].append(t1) @@ -910,7 +900,7 @@ def get_boundary_edges(tris: np.ndarray) -> np.ndarray: edges = [ (min(tri[0], tri[1]), max(tri[0], tri[1])), (min(tri[1], tri[2]), max(tri[1], tri[2])), - (min(tri[2], tri[0]), max(tri[2], tri[0])) + (min(tri[2], tri[0]), max(tri[2], tri[0])), ] # Count each edge @@ -923,9 +913,13 @@ def get_boundary_edges(tris: np.ndarray) -> np.ndarray: return np.array(boundary_edges, dtype=np.int32) -def smooth_triangle_mesh(verts: np.ndarray, tris: np.ndarray, - iterations: int = 1, lambda_factor: float = 0.5, - preserve_boundary: bool = True) -> np.ndarray: +def smooth_triangle_mesh( + verts: np.ndarray, + tris: np.ndarray, + iterations: int = 1, + lambda_factor: float = 0.5, + preserve_boundary: bool = True, +) -> np.ndarray: """ Apply Laplacian smoothing to a triangle mesh. @@ -1007,7 +1001,9 @@ def smooth_triangle_mesh(verts: np.ndarray, tris: np.ndarray, neighbor_avg /= len(neighbors) # Move vertex toward average of neighbors by lambda_factor - new_positions[i] = smoothed_verts[i] + lambda_factor * (neighbor_avg - smoothed_verts[i]) + new_positions[i] = smoothed_verts[i] + lambda_factor * ( + neighbor_avg - smoothed_verts[i] + ) # Update vertex positions for next iteration smoothed_verts = new_positions @@ -1015,10 +1011,13 @@ def smooth_triangle_mesh(verts: np.ndarray, tris: np.ndarray, return smoothed_verts -def decimate_triangle_mesh(verts: np.ndarray, tris: np.ndarray, - target_reduction: float = 0.5, - preserve_boundary: bool = True, - max_error: float = float('inf')) -> dict: +def decimate_triangle_mesh( + verts: np.ndarray, + tris: np.ndarray, + target_reduction: float = 0.5, + preserve_boundary: bool = True, + max_error: float = float("inf"), +) -> dict: """ Reduce the number of triangles in a mesh while preserving its shape. @@ -1045,7 +1044,7 @@ def decimate_triangle_mesh(verts: np.ndarray, tris: np.ndarray, # Quick return if no reduction needed if target_reduction == 0.0: - return {'vertices': verts.copy(), 'triangles': tris.copy()} + return {"vertices": verts.copy(), "triangles": tris.copy()} # Get mesh properties num_original_tris = len(tris) @@ -1054,7 +1053,7 @@ def decimate_triangle_mesh(verts: np.ndarray, tris: np.ndarray, # If target triangle count is very low, just return a simplified version if target_tris < 4: simplified_verts, simplified_tris = simplified_convex_hull(verts) - return {'vertices': simplified_verts, 'triangles': simplified_tris} + return {"vertices": simplified_verts, "triangles": simplified_tris} # Find boundary edges if needed boundary_edges = set() @@ -1116,7 +1115,7 @@ def decimate_triangle_mesh(verts: np.ndarray, tris: np.ndarray, for i in affected_tris: tri = new_tris[i] # Skip triangles that would be collapsed - if (v1_mapped in tri and v2_mapped in tri): + if v1_mapped in tri and v2_mapped in tri: continue # Check if the collapse would create a degenerate triangle @@ -1174,7 +1173,7 @@ def decimate_triangle_mesh(verts: np.ndarray, tris: np.ndarray, for i in range(len(final_tris)): final_tris[i] = [vertex_indices[v] for v in final_tris[i]] - return {'vertices': final_verts, 'triangles': np.array(final_tris)} + return {"vertices": final_verts, "triangles": np.array(final_tris)} def compute_mesh_normals(verts: np.ndarray, tris: np.ndarray) -> dict: @@ -1215,10 +1214,7 @@ def compute_mesh_normals(verts: np.ndarray, tris: np.ndarray) -> dict: lengths = np.where(lengths > 0, lengths, 1.0) # Avoid division by zero vertex_normals = vertex_normals / lengths - return { - 'face_normals': face_normals, - 'vertex_normals': vertex_normals - } + return {"face_normals": face_normals, "vertex_normals": vertex_normals} def simplified_convex_hull(verts: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: @@ -1255,12 +1251,7 @@ def simplified_convex_hull(verts: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: tetra_verts = np.array(extreme_points[:4]) # Create a tetrahedron - tetra_tris = np.array([ - [0, 1, 2], - [0, 1, 3], - [0, 2, 3], - [1, 2, 3] - ]) + tetra_tris = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]]) return tetra_verts, tetra_tris @@ -1291,8 +1282,4 @@ def validate_triangle_mesh(verts: np.ndarray, tris: np.ndarray) -> dict: is_valid = False errors.append(f"Validation error: {str(e)}") - return { - 'is_valid': is_valid and len(errors) == 0, - 'errors': errors - } - + return {"is_valid": is_valid and len(errors) == 0, "errors": errors} diff --git a/src/pyocc/graph.py b/src/pyocc/graph.py index dfc1a63..8f40d3e 100644 --- a/src/pyocc/graph.py +++ b/src/pyocc/graph.py @@ -4,25 +4,27 @@ This module converts CAD topology into NetworkX graphs for advanced analysis, including face adjacency and vertex adjacency graphs. """ -import numpy as np -from typing import Dict, List, Tuple, Optional, Any, Union -import logging try: import networkx as nx + HAS_NETWORKX = True except ImportError: HAS_NETWORKX = False nx = None -from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListIteratorOfListOfShape -from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_VERTEX +from OCC.Core.TopExp import TopExp_Explorer, topexp from OCC.Core.TopoDS import topods +from OCC.Core.TopTools import ( + TopTools_IndexedDataMapOfShapeListOfShape, + TopTools_ListIteratorOfListOfShape, +) class TopologyGraphError(Exception): """Exception raised for topology graph operations.""" + pass @@ -30,8 +32,7 @@ def _require_networkx(): """Check if NetworkX is available and raise an error if not.""" if not HAS_NETWORKX: raise ImportError( - "NetworkX is required for graph analysis. " - "Install it with: pip install networkx" + "NetworkX is required for graph analysis. " "Install it with: pip install networkx" ) @@ -62,12 +63,7 @@ def face_adjacency(shape): # Create mapping from edges to adjacent faces using new API edge_face_map = TopTools_IndexedDataMapOfShapeListOfShape() - topexp.MapShapesAndAncestors( - occ_shape, - TopAbs_EDGE, - TopAbs_FACE, - edge_face_map - ) + topexp.MapShapesAndAncestors(occ_shape, TopAbs_EDGE, TopAbs_FACE, edge_face_map) # Collect all faces and assign indices face_explorer = TopExp_Explorer(occ_shape, TopAbs_FACE) @@ -80,6 +76,7 @@ def face_adjacency(shape): # Import Face class here to avoid circular imports from pyocc.face import Face + face_obj = Face(topods.Face(face_shape)) faces.append(face_obj) face_to_index[face_shape] = face_index @@ -115,6 +112,7 @@ def face_adjacency(shape): # Import Edge class here to avoid circular imports from pyocc.edge import Edge + edge_obj = Edge(topods.Edge(edge_shape)) # Calculate edge continuity if possible @@ -123,17 +121,12 @@ def face_adjacency(shape): face1_obj = faces[face1_idx] face2_obj = faces[face2_idx] continuity = edge_obj.continuity(face1_obj, face2_obj) - except: + except Exception: pass # Add edge to graph with attributes - graph.add_edge( - face1_idx, - face2_idx, - edge=edge_obj, - continuity=continuity - ) - except: + graph.add_edge(face1_idx, face2_idx, edge=edge_obj, continuity=continuity) + except Exception: # Skip this edge if we can't process it continue @@ -186,6 +179,7 @@ def vertex_adjacency(shape): # Import Vertex class here to avoid circular imports from pyocc.vertex import Vertex + vertex_obj = Vertex(topods.Vertex(vertex_shape)) vertices.append(vertex_obj) vertex_to_index[vertex_shape] = vertex_index @@ -226,14 +220,11 @@ def vertex_adjacency(shape): if not graph.has_edge(vertex1_idx, vertex2_idx): # Import Edge class here to avoid circular imports from pyocc.edge import Edge + edge_obj = Edge(topods.Edge(edge_shape)) # Add edge to graph - graph.add_edge( - vertex1_idx, - vertex2_idx, - edge=edge_obj - ) + graph.add_edge(vertex1_idx, vertex2_idx, edge=edge_obj) edge_explorer.Next() @@ -273,12 +264,12 @@ def analyze_connectivity(graph, node_attribute="face"): avg_degree = sum(node_degrees.values()) / len(node_degrees) if node_degrees else 0 return { - 'connected_components': components, - 'num_components': num_components, - 'largest_component_size': largest_component_size, - 'is_connected': is_connected, - 'node_degrees': node_degrees, - 'avg_degree': avg_degree + "connected_components": components, + "num_components": num_components, + "largest_component_size": largest_component_size, + "is_connected": is_connected, + "node_degrees": node_degrees, + "avg_degree": avg_degree, } @@ -344,9 +335,9 @@ def find_vertex_valence_anomalies(vertex_graph, expected_valence=4): low_valence.append(vertex_idx) return { - 'high_valence': high_valence, - 'low_valence': low_valence, - 'valence_distribution': valence_distribution + "high_valence": high_valence, + "low_valence": low_valence, + "valence_distribution": valence_distribution, } @@ -367,7 +358,7 @@ def get_shared_edge(face_graph, face1_index, face2_index): """Get the edge shared between two adjacent faces.""" _require_networkx() if face_graph.has_edge(face1_index, face2_index): - return face_graph[face1_index][face2_index].get('edge') + return face_graph[face1_index][face2_index].get("edge") return None @@ -443,7 +434,7 @@ def path_distance(self, start_node, end_node): try: return nx.shortest_path_length(self.graph, start_node, end_node) except nx.NetworkXNoPath: - return float('inf') + return float("inf") def clustering_coefficient(self): """Calculate average clustering coefficient.""" @@ -509,14 +500,14 @@ def get_faces(self): """Get all face objects from the graph.""" faces = [] for node_id in self.graph.nodes(): - face = self.graph.nodes[node_id].get('face') + face = self.graph.nodes[node_id].get("face") if face is not None: faces.append(face) return faces def get_face(self, node_id): """Get face object for a given node ID.""" - return self.graph.nodes[node_id].get('face') + return self.graph.nodes[node_id].get("face") def get_face_neighbors(self, face_index): """ @@ -576,14 +567,14 @@ def get_vertices(self): """Get all vertex objects from the graph.""" vertices = [] for node_id in self.graph.nodes(): - vertex = self.graph.nodes[node_id].get('vertex') + vertex = self.graph.nodes[node_id].get("vertex") if vertex is not None: vertices.append(vertex) return vertices def get_vertex(self, node_id): """Get vertex object for a given node ID.""" - return self.graph.nodes[node_id].get('vertex') + return self.graph.nodes[node_id].get("vertex") def get_vertex_neighbors(self, vertex_index): """ diff --git a/src/pyocc/io.py b/src/pyocc/io.py index cc25457..f863c4b 100644 --- a/src/pyocc/io.py +++ b/src/pyocc/io.py @@ -4,28 +4,33 @@ This module provides comprehensive file import/export functionality for various CAD file formats including STEP, IGES, STL, and native OCC formats. """ + import logging from pathlib import Path -from typing import List, Union, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional, Union if TYPE_CHECKING: from .shape import Shape -from .compound import Compound -from OCC.Core.STEPControl import STEPControl_Reader, STEPControl_Writer, STEPControl_AsIs -from OCC.Core.IGESControl import IGESControl_Reader, IGESControl_Writer -from OCC.Core.StlAPI import StlAPI_Reader, StlAPI_Writer +from OCC.Core.BRep import BRep_Builder +from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh from OCC.Core.IFSelect import IFSelect_RetDone +from OCC.Core.IGESControl import IGESControl_Writer from OCC.Core.Interface import Interface_Static_SetCVal -from OCC.Core.BRepTools import BRepTools_ShapeSet -from OCC.Core.BRep import BRep_Builder +from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Writer +from OCC.Core.StlAPI import StlAPI_Reader, StlAPI_Writer from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape -from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh -from OCC.Extend.DataExchange import read_step_file, read_iges_file +from OCC.Extend.DataExchange import read_iges_file, read_step_file + +from .compound import Compound -def save_step(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], - schema: str = "AP203", verbosity: bool = False) -> bool: +def save_step( + shapes: Union["Shape", List["Shape"]], + filename: Union[str, Path], + schema: str = "AP203", + verbosity: bool = False, +) -> bool: """ Save shapes to a STEP file. @@ -73,7 +78,7 @@ def save_step(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], return False -def load_step(filename: Union[str, Path], verbosity: bool = False) -> Optional['Compound']: +def load_step(filename: Union[str, Path], verbosity: bool = False) -> Optional["Compound"]: """ Load shapes from a STEP file. @@ -85,7 +90,9 @@ def load_step(filename: Union[str, Path], verbosity: bool = False) -> Optional[' Compound containing all loaded shapes, or None if failed """ try: - from .compound import Compound # Lazy import to avoid circular dependency + from .compound import ( + Compound, # Lazy import to avoid circular dependency + ) shp = read_step_file(str(filename), as_compound=True, verbosity=verbosity) if shp is None: @@ -107,8 +114,9 @@ def load_step(filename: Union[str, Path], verbosity: bool = False) -> Optional[' return None -def save_iges(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], - verbosity: bool = False) -> bool: +def save_iges( + shapes: Union["Shape", List["Shape"]], filename: Union[str, Path], verbosity: bool = False +) -> bool: """ Save shapes to an IGES file. @@ -157,7 +165,7 @@ def save_iges(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], return False -def load_iges(filename: Union[str, Path], verbosity: bool = False) -> Optional['Compound']: +def load_iges(filename: Union[str, Path], verbosity: bool = False) -> Optional["Compound"]: """ Load shapes from an IGES file. @@ -169,7 +177,9 @@ def load_iges(filename: Union[str, Path], verbosity: bool = False) -> Optional[' Compound containing all loaded shapes, or None if failed """ try: - from .compound import Compound # Lazy import to avoid circular dependency + from .compound import ( + Compound, # Lazy import to avoid circular dependency + ) shp = read_iges_file(str(filename), verbosity=verbosity) if shp is None: @@ -191,9 +201,14 @@ def load_iges(filename: Union[str, Path], verbosity: bool = False) -> Optional[' return None -def save_stl(shape: 'Shape', filename: Union[str, Path], - linear_deflection: float = 0.1, angular_deflection: float = 0.1, - ascii_mode: bool = False, verbosity: bool = False) -> bool: +def save_stl( + shape: "Shape", + filename: Union[str, Path], + linear_deflection: float = 0.1, + angular_deflection: float = 0.1, + ascii_mode: bool = False, + verbosity: bool = False, +) -> bool: """ Save shape to an STL file (triangle mesh format). @@ -210,8 +225,9 @@ def save_stl(shape: 'Shape', filename: Union[str, Path], """ try: # Generate triangle mesh first - mesh = BRepMesh_IncrementalMesh(shape.topods_shape(), linear_deflection, - False, angular_deflection) + mesh = BRepMesh_IncrementalMesh( + shape.topods_shape(), linear_deflection, False, angular_deflection + ) mesh.Perform() if not mesh.IsDone(): @@ -240,7 +256,7 @@ def save_stl(shape: 'Shape', filename: Union[str, Path], return False -def load_stl(filename: Union[str, Path], verbosity: bool = False) -> Optional['Shape']: +def load_stl(filename: Union[str, Path], verbosity: bool = False) -> Optional["Shape"]: """ Load shape from an STL file. @@ -279,8 +295,9 @@ def load_stl(filename: Union[str, Path], verbosity: bool = False) -> Optional['S return None -def save_brep(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], - verbosity: bool = False) -> bool: +def save_brep( + shapes: Union["Shape", List["Shape"]], filename: Union[str, Path], verbosity: bool = False +) -> bool: """ Save shapes to native OCC .brep format (fast binary format). @@ -311,6 +328,7 @@ def save_brep(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], # Use BRepTools to write from OCC.Core.BRepTools import breptools_Write + success = breptools_Write(target_shape, str(filename)) if verbosity: @@ -327,7 +345,7 @@ def save_brep(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], return False -def load_brep(filename: Union[str, Path], verbosity: bool = False) -> Optional['Shape']: +def load_brep(filename: Union[str, Path], verbosity: bool = False) -> Optional["Shape"]: """ Load shape from native OCC .brep format. @@ -339,11 +357,12 @@ def load_brep(filename: Union[str, Path], verbosity: bool = False) -> Optional[' Shape object, or None if failed """ try: - from .shape import Shape # Lazy import to avoid circular dependency + from OCC.Core.BRep import BRep_Builder # Use BRepTools to read from OCC.Core.BRepTools import breptools_Read - from OCC.Core.BRep import BRep_Builder + + from .shape import Shape # Lazy import to avoid circular dependency shape = TopoDS_Shape() builder = BRep_Builder() @@ -367,8 +386,12 @@ def load_brep(filename: Union[str, Path], verbosity: bool = False) -> Optional[' # Utility functions for automatic format detection -def save_file(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], - verbosity: bool = False, **kwargs) -> bool: +def save_file( + shapes: Union["Shape", List["Shape"]], + filename: Union[str, Path], + verbosity: bool = False, + **kwargs, +) -> bool: """ Save shapes to file with automatic format detection based on extension. @@ -384,23 +407,23 @@ def save_file(shapes: Union['Shape', List['Shape']], filename: Union[str, Path], filename = Path(filename) extension = filename.suffix.lower() - if extension in ['.stp', '.step']: + if extension in [".stp", ".step"]: return save_step(shapes, filename, verbosity=verbosity, **kwargs) - elif extension in ['.igs', '.iges']: + elif extension in [".igs", ".iges"]: return save_iges(shapes, filename, verbosity=verbosity, **kwargs) - elif extension == '.stl': + elif extension == ".stl": if isinstance(shapes, list): if len(shapes) != 1: raise ValueError("STL format supports only single shapes") shapes = shapes[0] return save_stl(shapes, filename, verbosity=verbosity, **kwargs) - elif extension == '.brep': + elif extension == ".brep": return save_brep(shapes, filename, verbosity=verbosity, **kwargs) else: raise ValueError(f"Unsupported file format: {extension}") -def load_file(filename: Union[str, Path], verbosity: bool = False) -> Optional['Shape']: +def load_file(filename: Union[str, Path], verbosity: bool = False) -> Optional["Shape"]: """ Load shapes from file with automatic format detection based on extension. @@ -414,13 +437,13 @@ def load_file(filename: Union[str, Path], verbosity: bool = False) -> Optional[' filename = Path(filename) extension = filename.suffix.lower() - if extension in ['.stp', '.step']: + if extension in [".stp", ".step"]: return load_step(filename, verbosity=verbosity) - elif extension in ['.igs', '.iges']: + elif extension in [".igs", ".iges"]: return load_iges(filename, verbosity=verbosity) - elif extension == '.stl': + elif extension == ".stl": return load_stl(filename, verbosity=verbosity) - elif extension == '.brep': + elif extension == ".brep": return load_brep(filename, verbosity=verbosity) else: raise ValueError(f"Unsupported file format: {extension}") @@ -428,9 +451,14 @@ def load_file(filename: Union[str, Path], verbosity: bool = False) -> Optional[' # Export summary functions for convenience __all__ = [ - 'save_step', 'load_step', - 'save_iges', 'load_iges', - 'save_stl', 'load_stl', - 'save_brep', 'load_brep', - 'save_file', 'load_file' + "save_step", + "load_step", + "save_iges", + "load_iges", + "save_stl", + "load_stl", + "save_brep", + "load_brep", + "save_file", + "load_file", ] diff --git a/src/pyocc/jupyter_viewer.py b/src/pyocc/jupyter_viewer.py index 2eb70ec..8b73b91 100644 --- a/src/pyocc/jupyter_viewer.py +++ b/src/pyocc/jupyter_viewer.py @@ -4,35 +4,35 @@ This module provides interactive 3D visualization capabilities specifically designed for Jupyter notebooks using WebGL and modern web technologies. """ + +from typing import Dict, List, Optional + import numpy as np -from typing import Dict, List, Tuple, Optional, Any, Union, cast -import json -import base64 -import logging -from io import BytesIO # Check if Jupyter is available try: - from IPython.display import display, HTML, Javascript + from IPython.display import HTML, Javascript, display from ipywidgets import DOMWidget - from traitlets import Unicode, Dict as TraitDict, List as TraitList, Float, Bool + from traitlets import Bool, Unicode + from traitlets import Dict as TraitDict + from traitlets import List as TraitList + JUPYTER_AVAILABLE = True except ImportError: JUPYTER_AVAILABLE = False from .display_data import DisplayDataExtractor, DisplayMaterial, create_color_array -from .geometry.geom_utils import to_numpy class JupyterViewerBase: """ Base class for 3D visualization that works with or without Jupyter. - + This class provides the core functionality needed for visualization regardless of whether we're in a Jupyter environment. """ - - def __init__(self, width: str = '100%', height: str = '400px'): + + def __init__(self, width: str = "100%", height: str = "400px"): """Initialize the viewer.""" self.width = width self.height = height @@ -71,20 +71,20 @@ def display_shape(self, shape, color=None, transparency=0.0, material=None) -> s # Create shape data for WebGL shape_data = { - 'vertices': vertices.tolist(), - 'triangles': triangles.tolist(), - 'normals': normals.tolist(), - 'color': material.color.tolist(), - 'transparency': material.transparency, - 'shininess': material.shininess, - 'type': 'mesh' + "vertices": vertices.tolist(), + "triangles": triangles.tolist(), + "normals": normals.tolist(), + "color": material.color.tolist(), + "transparency": material.transparency, + "shininess": material.shininess, + "type": "mesh", } # Add to shapes dict shape_id = f"shape_{self._shape_counter}" self._shape_counter += 1 self._shapes[shape_id] = shape_data - + return shape_id def display_wireframe(self, shape, color=None, thickness=1.0) -> str: @@ -109,17 +109,17 @@ def display_wireframe(self, shape, color=None, thickness=1.0) -> str: # Create wireframe data for WebGL shape_data = { - 'points': points.tolist(), - 'lines': lines.tolist(), - 'color': color_array.tolist(), - 'thickness': thickness, - 'type': 'wireframe' + "points": points.tolist(), + "lines": lines.tolist(), + "color": color_array.tolist(), + "thickness": thickness, + "type": "wireframe", } shape_id = f"wireframe_{self._shape_counter}" self._shape_counter += 1 self._shapes[shape_id] = shape_data - + return shape_id def display_points(self, points: np.ndarray, color=None, size=5.0) -> str: @@ -140,20 +140,21 @@ def display_points(self, points: np.ndarray, color=None, size=5.0) -> str: color_array = create_color_array(color) shape_data = { - 'points': points.tolist(), - 'color': color_array.tolist(), - 'size': size, - 'type': 'points' + "points": points.tolist(), + "color": color_array.tolist(), + "size": size, + "type": "points", } shape_id = f"points_{self._shape_counter}" self._shape_counter += 1 self._shapes[shape_id] = shape_data - + return shape_id - def display_face_colormap(self, solid, values_for_faces: np.ndarray, - colormap='viridis', alpha=1.0) -> str: + def display_face_colormap( + self, solid, values_for_faces: np.ndarray, colormap="viridis", alpha=1.0 + ) -> str: """ Display a solid with faces colored by scalar values. @@ -202,21 +203,22 @@ def display_face_colormap(self, solid, values_for_faces: np.ndarray, vertex_offset += len(face.vertices) shape_data = { - 'vertices': all_vertices, - 'triangles': all_triangles, - 'colors': all_colors, - 'transparency': 1.0 - alpha, - 'type': 'colormap' + "vertices": all_vertices, + "triangles": all_triangles, + "colors": all_colors, + "transparency": 1.0 - alpha, + "type": "colormap", } shape_id = f"colormap_{self._shape_counter}" self._shape_counter += 1 self._shapes[shape_id] = shape_data - + return shape_id - def display_unit_vectors(self, points: np.ndarray, directions: np.ndarray, - length=1.0, color=None) -> str: + def display_unit_vectors( + self, points: np.ndarray, directions: np.ndarray, length=1.0, color=None + ) -> str: """ Display unit vectors at specified points. @@ -248,17 +250,17 @@ def display_unit_vectors(self, points: np.ndarray, directions: np.ndarray, lines.append([start_idx, start_idx + 1]) shape_data = { - 'points': line_points, - 'lines': lines, - 'color': color_array.tolist(), - 'thickness': 2.0, - 'type': 'vectors' + "points": line_points, + "lines": lines, + "color": color_array.tolist(), + "thickness": 2.0, + "type": "vectors", } shape_id = f"vectors_{self._shape_counter}" self._shape_counter += 1 self._shapes[shape_id] = shape_data - + return shape_id def remove_shape(self, shape_id: str) -> None: @@ -276,7 +278,9 @@ def clear_display(self) -> None: self._shapes = {} self._shape_counter = 0 - def set_camera_position(self, position: np.ndarray, target: Optional[np.ndarray] = None) -> None: + def set_camera_position( + self, position: np.ndarray, target: Optional[np.ndarray] = None + ) -> None: """ Set camera position and target. @@ -294,10 +298,10 @@ def fit_view(self) -> None: all_vertices = [] for shape_data in self._shapes.values(): - if 'vertices' in shape_data: - all_vertices.extend(shape_data['vertices']) - elif 'points' in shape_data: - all_vertices.extend(shape_data['points']) + if "vertices" in shape_data: + all_vertices.extend(shape_data["vertices"]) + elif "points" in shape_data: + all_vertices.extend(shape_data["points"]) if all_vertices: vertices = np.array(all_vertices) @@ -354,11 +358,11 @@ def _apply_colormap(self, values: np.ndarray, colormap: str) -> List[List[float] """ # Simple colormap implementations colormaps = { - 'viridis': self._viridis_colormap, - 'plasma': self._plasma_colormap, - 'jet': self._jet_colormap, - 'hot': self._hot_colormap, - 'cool': self._cool_colormap + "viridis": self._viridis_colormap, + "plasma": self._plasma_colormap, + "jet": self._jet_colormap, + "hot": self._hot_colormap, + "cool": self._cool_colormap, } colormap_func = colormaps.get(colormap, self._viridis_colormap) @@ -369,24 +373,32 @@ def _viridis_colormap(self, value: float) -> List[float]: # Simplified viridis colormap if value < 0.25: t = value * 4 - return [0.267004 * (1-t) + 0.282623 * t, - 0.004874 * (1-t) + 0.140926 * t, - 0.329415 * (1-t) + 0.457517 * t] + return [ + 0.267004 * (1 - t) + 0.282623 * t, + 0.004874 * (1 - t) + 0.140926 * t, + 0.329415 * (1 - t) + 0.457517 * t, + ] elif value < 0.5: t = (value - 0.25) * 4 - return [0.282623 * (1-t) + 0.253935 * t, - 0.140926 * (1-t) + 0.265254 * t, - 0.457517 * (1-t) + 0.574897 * t] + return [ + 0.282623 * (1 - t) + 0.253935 * t, + 0.140926 * (1 - t) + 0.265254 * t, + 0.457517 * (1 - t) + 0.574897 * t, + ] elif value < 0.75: t = (value - 0.5) * 4 - return [0.253935 * (1-t) + 0.206756 * t, - 0.265254 * (1-t) + 0.371758 * t, - 0.574897 * (1-t) + 0.553117 * t] + return [ + 0.253935 * (1 - t) + 0.206756 * t, + 0.265254 * (1 - t) + 0.371758 * t, + 0.574897 * (1 - t) + 0.553117 * t, + ] else: t = (value - 0.75) * 4 - return [0.206756 * (1-t) + 0.993248 * t, - 0.371758 * (1-t) + 0.906157 * t, - 0.553117 * (1-t) + 0.143936 * t] + return [ + 0.206756 * (1 - t) + 0.993248 * t, + 0.371758 * (1 - t) + 0.906157 * t, + 0.553117 * (1 - t) + 0.143936 * t, + ] def _plasma_colormap(self, value: float) -> List[float]: """Plasma colormap implementation.""" @@ -409,12 +421,12 @@ def _jet_colormap(self, value: float) -> List[float]: def _hot_colormap(self, value: float) -> List[float]: """Hot colormap implementation.""" - if value < 1/3: + if value < 1 / 3: return [3.0 * value, 0.0, 0.0] - elif value < 2/3: - return [1.0, 3.0 * (value - 1/3), 0.0] + elif value < 2 / 3: + return [1.0, 3.0 * (value - 1 / 3), 0.0] else: - return [1.0, 1.0, 3.0 * (value - 2/3)] + return [1.0, 1.0, 3.0 * (value - 2 / 3)] def _cool_colormap(self, value: float) -> List[float]: """Cool colormap implementation.""" @@ -427,112 +439,117 @@ def _cool_colormap(self, value: float) -> List[float]: class JupyterViewerWidget(DOMWidget, JupyterViewerBase): """ Jupyter widget for 3D visualization using WebGL. - + This widget provides interactive 3D visualization in Jupyter notebooks with support for shape display, selection, and manipulation. """ + # Widget traits - _model_name = Unicode('JupyterViewerModel').tag(sync=True) - _model_module = Unicode('pyocc-jupyter').tag(sync=True) - _model_module_version = Unicode('^0.1.0').tag(sync=True) - _view_name = Unicode('JupyterViewerView').tag(sync=True) - _view_module = Unicode('pyocc-jupyter').tag(sync=True) - _view_module_version = Unicode('^0.1.0').tag(sync=True) - + _model_name = Unicode("JupyterViewerModel").tag(sync=True) + _model_module = Unicode("pyocc-jupyter").tag(sync=True) + _model_module_version = Unicode("^0.1.0").tag(sync=True) + _view_name = Unicode("JupyterViewerView").tag(sync=True) + _view_module = Unicode("pyocc-jupyter").tag(sync=True) + _view_module_version = Unicode("^0.1.0").tag(sync=True) + # Widget traits shapes = TraitDict({}).tag(sync=True) camera_position = TraitList([10.0, 10.0, 10.0]).tag(sync=True) camera_target = TraitList([0.0, 0.0, 0.0]).tag(sync=True) background_color = TraitList([0.9, 0.9, 0.9]).tag(sync=True) - width = Unicode('100%').tag(sync=True) - height = Unicode('400px').tag(sync=True) + width = Unicode("100%").tag(sync=True) + height = Unicode("400px").tag(sync=True) axes_visible = Bool(True).tag(sync=True) grid_visible = Bool(True).tag(sync=True) wireframe_mode = Bool(False).tag(sync=True) - - def __init__(self, width='100%', height='400px', **kwargs): + + def __init__(self, width="100%", height="400px", **kwargs): """Initialize the Jupyter viewer widget.""" DOMWidget.__init__(self, **kwargs) JupyterViewerBase.__init__(self, width, height) self.width = width self.height = height - + # Override methods to update widget traits def display_shape(self, shape, color=None, transparency=0.0, material=None) -> str: """Display a shape and update the widget.""" shape_id = super().display_shape(shape, color, transparency, material) self.shapes = self._shapes return shape_id - + def display_wireframe(self, shape, color=None, thickness=1.0) -> str: """Display wireframe and update the widget.""" shape_id = super().display_wireframe(shape, color, thickness) self.shapes = self._shapes return shape_id - + def display_points(self, points: np.ndarray, color=None, size=5.0) -> str: """Display points and update the widget.""" shape_id = super().display_points(points, color, size) self.shapes = self._shapes return shape_id - - def display_face_colormap(self, solid, values_for_faces: np.ndarray, - colormap='viridis', alpha=1.0) -> str: + + def display_face_colormap( + self, solid, values_for_faces: np.ndarray, colormap="viridis", alpha=1.0 + ) -> str: """Display face colormap and update the widget.""" shape_id = super().display_face_colormap(solid, values_for_faces, colormap, alpha) self.shapes = self._shapes return shape_id - - def display_unit_vectors(self, points: np.ndarray, directions: np.ndarray, - length=1.0, color=None) -> str: + + def display_unit_vectors( + self, points: np.ndarray, directions: np.ndarray, length=1.0, color=None + ) -> str: """Display unit vectors and update the widget.""" shape_id = super().display_unit_vectors(points, directions, length, color) self.shapes = self._shapes return shape_id - + def remove_shape(self, shape_id: str) -> None: """Remove a shape and update the widget.""" super().remove_shape(shape_id) self.shapes = self._shapes - + def clear_display(self) -> None: """Clear all shapes and update the widget.""" super().clear_display() self.shapes = {} - - def set_camera_position(self, position: np.ndarray, target: Optional[np.ndarray] = None) -> None: + + def set_camera_position( + self, position: np.ndarray, target: Optional[np.ndarray] = None + ) -> None: """Set camera position and update the widget.""" super().set_camera_position(position, target) self.camera_position = self._camera_position if target is not None: self.camera_target = self._camera_target - + def fit_view(self) -> None: """Fit view to shapes and update the widget.""" super().fit_view() self.camera_position = self._camera_position self.camera_target = self._camera_target - + def set_background_color(self, color) -> None: """Set background color and update the widget.""" super().set_background_color(color) self.background_color = self._background_color - + def enable_wireframe(self) -> None: """Enable wireframe mode and update the widget.""" super().enable_wireframe() self.wireframe_mode = True - + def disable_wireframe(self) -> None: """Disable wireframe mode and update the widget.""" super().disable_wireframe() self.wireframe_mode = False - + def show_axes(self, show=True) -> None: """Show/hide axes and update the widget.""" super().show_axes(show) self.axes_visible = show - + def show_grid(self, show=True) -> None: """Show/hide grid and update the widget.""" super().show_grid(show) @@ -544,7 +561,7 @@ def show_grid(self, show=True) -> None: # Define a placeholder class when Jupyter is not available class JupyterViewer: """Placeholder for JupyterViewer when Jupyter is not available.""" - + def __init__(self, *args, **kwargs): """Initialize the placeholder.""" raise ImportError("Jupyter packages not available. Install ipywidgets and IPython.") @@ -566,17 +583,17 @@ def display_shape(shape, **kwargs): """ if not JUPYTER_AVAILABLE: raise ImportError("Jupyter packages not available. Install ipywidgets and IPython.") - + # Create the appropriate viewer class viewer = JupyterViewerWidget() if JUPYTER_AVAILABLE else JupyterViewerBase() - + # Display the shape viewer.display_shape(shape, **kwargs) - + # Display the widget if in Jupyter if JUPYTER_AVAILABLE: display(viewer) - + return viewer @@ -594,7 +611,7 @@ def display_shapes(shapes, colors=None, **kwargs): """ if not JUPYTER_AVAILABLE: raise ImportError("Jupyter packages not available. Install ipywidgets and IPython.") - + # Create the appropriate viewer class viewer = JupyterViewerWidget() if JUPYTER_AVAILABLE else JupyterViewerBase() @@ -602,16 +619,16 @@ def display_shapes(shapes, colors=None, **kwargs): for i, shape in enumerate(shapes): shape_kwargs = dict(kwargs) if colors is not None and i < len(colors): - shape_kwargs['color'] = colors[i] + shape_kwargs["color"] = colors[i] viewer.display_shape(shape, **shape_kwargs) # Fit view to all shapes viewer.fit_view() - + # Display the widget if in Jupyter if JUPYTER_AVAILABLE: display(viewer) - + return viewer diff --git a/src/pyocc/plane.py b/src/pyocc/plane.py index 6910ae4..ed31e4a 100644 --- a/src/pyocc/plane.py +++ b/src/pyocc/plane.py @@ -3,9 +3,10 @@ """ from typing import Optional -from OCC.Core.gp import gp_Pln, gp_Ax3, gp_Pnt, gp_Dir, gp_Vec, gp_Trsf, gp_Ax1 -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform + import numpy as np +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform +from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pln, gp_Pnt, gp_Trsf, gp_Vec from .shape import Shape @@ -97,10 +98,10 @@ def project_point(self, point): def _extract_gp_pln_from_shape(shape): """Extract a gp_Pln from a planar face shape.""" from OCC.Core.BRep import BRep_Tool + from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.Geom import Handle_Geom_Plane # type: ignore - from OCC.Core.TopoDS import topods from OCC.Core.GeomAbs import GeomAbs_Plane - from OCC.Core.BRepAdaptor import BRepAdaptor_Surface + from OCC.Core.TopoDS import topods face = topods.Face(shape) surf = BRep_Tool.Surface(face) @@ -112,7 +113,9 @@ def _extract_gp_pln_from_shape(shape): raise RuntimeError("Failed to extract Geom_Plane from surface") return geom_plane_handle.Pln() - def rotate_about_axis(self, axis_point: tuple, axis_direction: tuple, angle_degrees: float) -> "Plane": + def rotate_about_axis( + self, axis_point: tuple, axis_direction: tuple, angle_degrees: float + ) -> "Plane": """ Rotate the plane about an arbitrary axis. """ @@ -122,6 +125,7 @@ def rotate_about_axis(self, axis_point: tuple, axis_direction: tuple, angle_degr trsf = gp_Trsf() trsf.SetRotation(rotation_axis, np.radians(angle_degrees)) from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace + # Make a face from the plane face = BRepBuilderAPI_MakeFace(self.plane, -1e3, 1e3, -1e3, 1e3).Face() transformer = BRepBuilderAPI_Transform(face, trsf) @@ -175,6 +179,7 @@ def translate(self, translation: tuple) -> "Plane": vec = gp_Vec(translation[0], translation[1], translation[2]) trsf.SetTranslation(vec) from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace + face = BRepBuilderAPI_MakeFace(self.plane, -1e3, 1e3, -1e3, 1e3).Face() transformer = BRepBuilderAPI_Transform(face, trsf) if not transformer.IsDone(): @@ -183,7 +188,9 @@ def translate(self, translation: tuple) -> "Plane": new_gp_pln = self._extract_gp_pln_from_shape(transformed_shape) return Plane(plane=new_gp_pln) - def transform(self, translation: Optional[tuple] = None, rotation_matrix: Optional[np.ndarray] = None) -> "Plane": + def transform( + self, translation: Optional[tuple] = None, rotation_matrix: Optional[np.ndarray] = None + ) -> "Plane": """ Transform the plane with translation and/or rotation. """ @@ -191,11 +198,13 @@ def transform(self, translation: Optional[tuple] = None, rotation_matrix: Option if rotation_matrix is not None: angle = np.arccos((np.trace(rotation_matrix) - 1) / 2) if abs(angle) > 1e-6: - axis = np.array([ - rotation_matrix[2, 1] - rotation_matrix[1, 2], - rotation_matrix[0, 2] - rotation_matrix[2, 0], - rotation_matrix[1, 0] - rotation_matrix[0, 1], - ]) + axis = np.array( + [ + rotation_matrix[2, 1] - rotation_matrix[1, 2], + rotation_matrix[0, 2] - rotation_matrix[2, 0], + rotation_matrix[1, 0] - rotation_matrix[0, 1], + ] + ) axis = axis / np.linalg.norm(axis) rot_axis = gp_Ax1(gp_Pnt(0, 0, 0), gp_Dir(axis[0], axis[1], axis[2])) rot_trsf = gp_Trsf() @@ -207,6 +216,7 @@ def transform(self, translation: Optional[tuple] = None, rotation_matrix: Option trans_trsf.SetTranslation(vec) trsf.Multiply(trans_trsf) from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace + face = BRepBuilderAPI_MakeFace(self.plane, -1e3, 1e3, -1e3, 1e3).Face() transformer = BRepBuilderAPI_Transform(face, trsf) if not transformer.IsDone(): @@ -260,11 +270,11 @@ def create_offset_plane(self, distance: float) -> "Plane": """ if distance == 0: return Plane(origin=self.origin, normal=self.normal) - + # Calculate new origin by moving along normal direction normal_vec = np.array(self.normal) origin_vec = np.array(self.origin) offset_vec = distance * normal_vec new_origin = origin_vec + offset_vec - + return Plane(origin=tuple(new_origin), normal=self.normal) diff --git a/src/pyocc/shape.py b/src/pyocc/shape.py index c75136d..d2c6862 100644 --- a/src/pyocc/shape.py +++ b/src/pyocc/shape.py @@ -6,35 +6,30 @@ and provides common functionality for geometry operations. """ -import os -import numpy as np -from typing import List, Tuple, Union, Optional, Dict, Set, Any +from typing import Dict, List, Optional, Tuple, Union -from OCC.Core.TopoDS import TopoDS_Shape, TopoDS_Iterator, TopoDS_Compound, topods -from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_EDGE, TopAbs_VERTEX, TopAbs_WIRE -from OCC.Core.TopAbs import TopAbs_SHELL, TopAbs_SOLID, TopAbs_COMPSOLID, TopAbs_COMPOUND -from OCC.Core.gp import gp_Pnt, gp_Ax1, gp_Vec, gp_Dir, gp_Trsf -from OCC.Core.BRep import BRep_Tool -from OCC.Core.BRepCheck import BRepCheck_Analyzer +import numpy as np from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform -from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape from OCC.Core.BRepTools import breptools -from OCC.Core.TCollection import TCollection_AsciiString -from OCC.Core.Precision import precision - -from .base import BoundingBoxMixin, EdgeContainerMixin -# Remove circular import -# from .vertex import Vertex -from .edge import Edge -# Remove circular imports - these will be imported lazily in pyocc_shape method -# from .wire import Wire -# from .face import Face -# Remove circular import -# from .shell import Shell -from .solid import Solid -from .compound import Compound -from .base import FaceContainerMixin +from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCC.Core.TopAbs import ( + TopAbs_COMPOUND, + TopAbs_COMPSOLID, + TopAbs_EDGE, + TopAbs_FACE, + TopAbs_SHELL, + TopAbs_SOLID, + TopAbs_VERTEX, + TopAbs_WIRE, +) +from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Iterator, TopoDS_Shape, topods + +# The concrete shape wrappers (Vertex, Edge, Wire, Face, Shell, Solid, Compound) all subclass +# Shape, so they import this module. To avoid the resulting import cycle they are imported lazily +# inside pyocc_shape() rather than at module top level. +from .base import BoundingBoxMixin, EdgeContainerMixin, FaceContainerMixin class Shape(FaceContainerMixin, EdgeContainerMixin, BoundingBoxMixin): @@ -142,31 +137,68 @@ def pyocc_shape(cls, topods_shape: TopoDS_Shape): shape_type = topods_shape.ShapeType() + # Down-cast the generic TopoDS_Shape to its concrete type before wrapping. + # A shape obtained from a file reader or a topology explorer reports the + # right ShapeType() but is still a plain TopoDS_Shape, which the concrete + # wrappers' isinstance() checks would otherwise reject. if shape_type == TopAbs_VERTEX: # Import Vertex here to avoid circular import from .vertex import Vertex - return Vertex(topods_shape) + + return Vertex(topods.Vertex(topods_shape)) elif shape_type == TopAbs_EDGE: - return Edge(topods_shape) + # Import Edge here to avoid circular import + from .edge import Edge + + return Edge(topods.Edge(topods_shape)) elif shape_type == TopAbs_WIRE: # Import Wire here to avoid circular import from .wire import Wire - return Wire(topods_shape) + + return Wire(topods.Wire(topods_shape)) elif shape_type == TopAbs_FACE: # Import Face here to avoid circular import from .face import Face - return Face(topods_shape) + + return Face(topods.Face(topods_shape)) elif shape_type == TopAbs_SHELL: from .shell import Shell - return Shell(topods_shape) + + return Shell(topods.Shell(topods_shape)) elif shape_type == TopAbs_SOLID: - return Solid(topods_shape) - elif shape_type in (TopAbs_COMPOUND, TopAbs_COMPSOLID): - return Compound(topods_shape) + # Import Solid here to avoid circular import + from .solid import Solid + + return Solid(topods.Solid(topods_shape)) + elif shape_type == TopAbs_COMPOUND: + # Import Compound here to avoid circular import + from .compound import Compound + + return Compound(topods.Compound(topods_shape)) + elif shape_type == TopAbs_COMPSOLID: + # Import Compound here to avoid circular import + from .compound import Compound + + return Compound(topods.CompSolid(topods_shape)) else: # Default to base Shape class if we don't have a specific wrapper return Shape(topods_shape) + @classmethod + def create_from_topods(cls, topods_shape: TopoDS_Shape): + """ + Wrap a raw OpenCASCADE ``TopoDS_Shape`` in the appropriate pyocc class. + + Alias for :meth:`pyocc_shape`; used by the STL/BREP loaders in ``io.py``. + + Args: + topods_shape: The OpenCASCADE shape to wrap + + Returns: + The appropriate pyocc shape wrapper for the shape's type + """ + return cls.pyocc_shape(topods_shape) + def __hash__(self) -> int: """ Hash function for Shape objects. Enables shapes to be used in sets and as dict keys. @@ -212,11 +244,10 @@ def find_closest_point_data( datum: A 3D point as a tuple, list, or numpy array Returns: - dict: Data about the closest point including: - 'parameter': Parameter value(s) on shape (if applicable) - 'point': 3D coordinates of closest point - 'distance': Distance from datum to closest point - 'shape': The sub-shape where the closest point was found + Tuple of (point, sub_shape, distance): + - point: numpy array [x, y, z] of the closest point + - sub_shape: the pyocc sub-shape where the closest point was found + - distance: distance from datum to the closest point None if no solution found """ # Convert input point to gp_Pnt @@ -242,12 +273,11 @@ def find_closest_point_data( closest_pnt = extrema.PointOnShape1(1) # 1-indexed closest_shape = extrema.SupportOnShape1(1) - return { - "point": np.array([closest_pnt.X(), closest_pnt.Y(), closest_pnt.Z()]), - "distance": min_dist, - "shape": Shape.pyocc_shape(closest_shape), - "parameter": None, # Would need shape-specific parameter extraction - } + return ( + np.array([closest_pnt.X(), closest_pnt.Y(), closest_pnt.Z()]), + Shape.pyocc_shape(closest_shape), + min_dist, + ) else: return None @@ -473,7 +503,6 @@ def convert_geometric_identity_transforms_to_identity(self, tolerance: float = 1 """ from OCC.Core.BRep import BRep_Builder from OCC.Core.TopLoc import TopLoc_Location - from OCC.Core.gp import gp_Trsf def is_nearly_identity(location: TopLoc_Location, tol: float) -> bool: """Check if a location transform is nearly identity within tolerance.""" diff --git a/src/pyocc/shell.py b/src/pyocc/shell.py index ad5149a..0eb93bd 100644 --- a/src/pyocc/shell.py +++ b/src/pyocc/shell.py @@ -4,29 +4,21 @@ This module provides the Shell class which represents a connected set of faces. """ -import numpy as np import logging -from OCC.Core.TopoDS import TopoDS_Shell -from OCC.Core.BRep import BRep_Tool + +import numpy as np from OCC.Core.BRepCheck import BRepCheck_Shell +from OCC.Core.TopoDS import TopoDS_Shell -from .base import ( - EdgeContainerMixin, - VertexContainerMixin, - WireContainerMixin, - FaceContainerMixin, - SurfacePropertiesMixin, - BoundingBoxMixin, -) +from .base import SurfacePropertiesMixin, VertexContainerMixin, WireContainerMixin +from .shape import Shape class Shell( - EdgeContainerMixin, + Shape, VertexContainerMixin, WireContainerMixin, - FaceContainerMixin, SurfacePropertiesMixin, - BoundingBoxMixin, ): """ A shell representing a connected set of faces. @@ -46,9 +38,9 @@ def make_from_faces(faces): Returns: Shell object or None if creation fails """ - from OCC.Core.TopoDS import TopoDS_Shell from OCC.Core.BRep import BRep_Builder - from OCC.Core.BRepCheck import BRepCheck_Shell, BRepCheck_NoError + from OCC.Core.BRepCheck import BRepCheck_NoError, BRepCheck_Shell + from OCC.Core.TopoDS import TopoDS_Shell # Create an empty shell shell = TopoDS_Shell() @@ -147,7 +139,6 @@ def find_closest_point_data(self, datum): # Check all faces to find closest min_dist = float("inf") closest_point = None - closest_uv = None closest_face = None for face in self.faces(): @@ -155,7 +146,6 @@ def find_closest_point_data(self, datum): if dist < min_dist: min_dist = dist closest_point = point - closest_uv = uv closest_face = face if closest_face is None: diff --git a/src/pyocc/sketch.py b/src/pyocc/sketch.py index 0e0abbb..b671323 100644 --- a/src/pyocc/sketch.py +++ b/src/pyocc/sketch.py @@ -2,38 +2,37 @@ Sketch class for parametric CAD operations. """ -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple -from OCC.Core.TopoDS import TopoDS_Shape -from OCC.Core.gp import gp_Pnt, gp_Vec, gp_Dir, gp_Ax3, gp_XY, gp_Ax2d, gp_Pnt2d, gp_Trsf import numpy as np +from OCC.Core.gp import gp_Pnt, gp_Pnt2d, gp_Vec -from .shape import Shape +from .edge import Edge from .plane import Plane +from .shape import Shape from .wire import Wire -from .edge import Edge class Sketch(Shape): """ A 2D sketch that can be used to create 3D shapes. """ - + def __init__(self, plane: Optional[Plane] = None): """ Initialize a sketch on a plane. - + Args: plane: The plane to create the sketch on. Defaults to the XY plane. """ # Create an empty shape for the base class from OCC.Core.BRep import BRep_Builder from OCC.Core.TopoDS import TopoDS_Compound - + compound = TopoDS_Compound() builder = BRep_Builder() builder.MakeCompound(compound) - + super().__init__(compound) self.plane = plane or Plane("XY") self.edges: List[Edge] = [] @@ -42,63 +41,63 @@ def __init__(self, plane: Optional[Plane] = None): self.coordinate_system = self.plane.to_ax3() # Add geometry attribute for compatibility with Workplane self.geometry = [] - + def add_geometry(self, geom): """ Add geometry to the sketch. - + Args: geom: Geometry object to add - + Returns: Self for chaining """ self.geometry.append(geom) return self - + def add_edge(self, edge: Edge) -> "Sketch": """ Add an edge to the sketch. - + Args: edge: The edge to add - + Returns: Self for chaining """ self.edges.append(edge) return self - + def add_wire(self, wire: Wire) -> "Sketch": """ Add a wire to the sketch. - + Args: wire: The wire to add - + Returns: Self for chaining """ self.wires.append(wire) return self - + def add_constraint(self, constraint) -> "Sketch": """ Add a constraint to the sketch. - + Args: constraint: The constraint to add - + Returns: Self for chaining """ self.constraints.append(constraint) return self - + def solve(self) -> bool: """ Solve the sketch constraints. - + Returns: True if the constraints were successfully solved """ @@ -106,11 +105,11 @@ def solve(self) -> bool: if not constraint.apply(): return False return True - + def to_wire(self) -> Wire: """ Convert the sketch to a wire. - + Returns: A wire representing the sketch """ @@ -123,161 +122,164 @@ def to_wire(self) -> Wire: return wire else: raise ValueError("Sketch has no edges or wires") - + def to_face(self): """ Convert the sketch to a face. - + Returns: A Face object representing the sketch """ from .face import Face + wire = self.to_wire() return Face.make_from_wire(wire) - + def line(self, start_point: Tuple[float, float], end_point: Tuple[float, float]) -> "Sketch": """ Add a line to the sketch. - + Args: start_point: The start point (x, y) end_point: The end point (x, y) - + Returns: Self for chaining """ # Project the 2D points onto the sketch plane start_3d = self._project_to_3d(start_point) end_3d = self._project_to_3d(end_point) - + # Create an edge and add it to the sketch edge = Edge.make_line_from_points(start_3d, end_3d) self.add_edge(edge) return self - + def circle(self, center: Tuple[float, float], radius: float) -> "Sketch": """ Add a circle to the sketch. - + Args: center: The center point (x, y) radius: The radius - + Returns: Self for chaining """ # Project the 2D center point onto the sketch plane center_3d = self._project_to_3d(center) - + # Create a circle edge and add it to the sketch edge = Edge.make_circle(center_3d, radius, self.plane.get_normal()) if edge is not None: self.add_edge(edge) return self - - def arc(self, center: Tuple[float, float], radius: float, - start_angle: float, end_angle: float) -> "Sketch": + + def arc( + self, center: Tuple[float, float], radius: float, start_angle: float, end_angle: float + ) -> "Sketch": """ Add an arc to the sketch. - + Args: center: The center point (x, y) radius: The radius start_angle: The start angle in radians end_angle: The end angle in radians - + Returns: Self for chaining """ # Project the 2D center point onto the sketch plane center_3d = self._project_to_3d(center) - + # Calculate start and end points from angles start_x = center_3d[0] + radius * np.cos(start_angle) start_y = center_3d[1] + radius * np.sin(start_angle) start_z = center_3d[2] - + end_x = center_3d[0] + radius * np.cos(end_angle) end_y = center_3d[1] + radius * np.sin(end_angle) end_z = center_3d[2] - + # Calculate a point in the middle of the arc mid_angle = (start_angle + end_angle) / 2 mid_x = center_3d[0] + radius * np.cos(mid_angle) mid_y = center_3d[1] + radius * np.sin(mid_angle) mid_z = center_3d[2] - + # Create an arc edge using three points start_point = (start_x, start_y, start_z) mid_point = (mid_x, mid_y, mid_z) end_point = (end_x, end_y, end_z) - + edge = Edge.make_arc_of_circle(start_point, mid_point, end_point) if edge is not None: self.add_edge(edge) return self - - def rectangle(self, width: float, height: float, - center: Optional[Tuple[float, float]] = None) -> "Sketch": + + def rectangle( + self, width: float, height: float, center: Optional[Tuple[float, float]] = None + ) -> "Sketch": """ Add a rectangle to the sketch. - + Args: width: The width height: The height center: The center point (x, y). Defaults to (0, 0). - + Returns: Self for chaining """ center = center or (0, 0) half_width = width / 2 half_height = height / 2 - + # Create the rectangle corners points = [ (center[0] - half_width, center[1] - half_height), (center[0] + half_width, center[1] - half_height), (center[0] + half_width, center[1] + half_height), - (center[0] - half_width, center[1] + half_height) + (center[0] - half_width, center[1] + half_height), ] - + # Create edges connecting the corners for i in range(4): self.line(points[i], points[(i + 1) % 4]) - + return self - + def _project_to_3d(self, point_2d: Tuple[float, float]) -> Tuple[float, float, float]: """ Project a 2D point onto the sketch plane using the plane's coordinate system. - + Args: point_2d: The 2D point (x, y) in the sketch's local coordinate system - + Returns: The 3D point (x, y, z) in global coordinates """ # Create a 2D point in the sketch's coordinate system - pnt2d = gp_Pnt2d(point_2d[0], point_2d[1]) - + gp_Pnt2d(point_2d[0], point_2d[1]) + # Get the coordinate system of the plane ax3 = self.coordinate_system - + # Calculate the 3D point by transforming along the plane's axes x_dir = ax3.XDirection() y_dir = ax3.YDirection() origin = ax3.Location() - + # Compute the 3D point: origin + x*x_dir + y*y_dir x_component = gp_Vec(x_dir).Multiplied(point_2d[0]) y_component = gp_Vec(y_dir).Multiplied(point_2d[1]) - + # Create the final 3D point result_pnt = gp_Pnt( origin.X() + x_component.X() + y_component.X(), origin.Y() + x_component.Y() + y_component.Y(), - origin.Z() + x_component.Z() + y_component.Z() + origin.Z() + x_component.Z() + y_component.Z(), ) - - return (result_pnt.X(), result_pnt.Y(), result_pnt.Z()) \ No newline at end of file + + return (result_pnt.X(), result_pnt.Y(), result_pnt.Z()) diff --git a/src/pyocc/solid.py b/src/pyocc/solid.py index 9bd50f0..1dbe345 100644 --- a/src/pyocc/solid.py +++ b/src/pyocc/solid.py @@ -3,29 +3,44 @@ This module provides the Solid class which represents a closed volume bounded by shells. """ -import numpy as np + import logging -from OCC.Core.TopoDS import TopoDS_Solid -from OCC.Core.BRep import BRep_Tool + +import numpy as np from OCC.Core.BRepCheck import BRepCheck_Analyzer from OCC.Core.BRepPrimAPI import ( - BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, - BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeCone, - BRepPrimAPI_MakeTorus + BRepPrimAPI_MakeBox, + BRepPrimAPI_MakeCone, + BRepPrimAPI_MakeCylinder, + BRepPrimAPI_MakeSphere, + BRepPrimAPI_MakeTorus, ) +from OCC.Core.TopoDS import TopoDS_Solid from .base import ( - EdgeContainerMixin, VertexContainerMixin, WireContainerMixin, - FaceContainerMixin, ShellContainerMixin, BoundingBoxMixin, - SurfacePropertiesMixin, VolumePropertiesMixin, TriangulatorMixin, - BottomUpFaceIterator, BottomUpEdgeIterator + BottomUpEdgeIterator, + BottomUpFaceIterator, + ShellContainerMixin, + SurfacePropertiesMixin, + TriangulatorMixin, + VertexContainerMixin, + VolumePropertiesMixin, + WireContainerMixin, ) - - -class Solid(EdgeContainerMixin, VertexContainerMixin, - WireContainerMixin, FaceContainerMixin, ShellContainerMixin, - BoundingBoxMixin, SurfacePropertiesMixin, VolumePropertiesMixin, - TriangulatorMixin, BottomUpFaceIterator, BottomUpEdgeIterator): +from .shape import Shape + + +class Solid( + Shape, + VertexContainerMixin, + WireContainerMixin, + ShellContainerMixin, + SurfacePropertiesMixin, + VolumePropertiesMixin, + TriangulatorMixin, + BottomUpFaceIterator, + BottomUpEdgeIterator, +): """ A solid representing a closed volume. @@ -80,9 +95,10 @@ def outer_shell(self): Returns: Shell object representing the outer boundary """ - from .shell import Shell from OCC.Core.BRepClass3d import BRepClass3d_SolidExplorer + from .shell import Shell + explorer = BRepClass3d_SolidExplorer(self.topods_solid()) shell = explorer.OuterShell() @@ -95,7 +111,6 @@ def inner_shells(self): Returns: List of Shell objects representing inner voids """ - from .shell import Shell # The first shell from shell iterator is the outer shell shells = list(self.shells()) @@ -127,10 +142,11 @@ def check_unique_oriented_edges(self): True if the condition is met (solid is manifold) """ try: - from OCC.Core.TopExp import TopExp_Explorer + from collections import defaultdict + from OCC.Core.TopAbs import TopAbs_EDGE + from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopoDS import topods - from collections import defaultdict # Dictionary to count edge orientations # Key: edge hash, Value: [forward_count, reversed_count] @@ -147,6 +163,7 @@ def check_unique_oriented_edges(self): # Count orientation from OCC.Core.TopAbs import TopAbs_FORWARD, TopAbs_REVERSED + orientation = edge_shape.Orientation() if orientation == TopAbs_FORWARD: @@ -186,7 +203,6 @@ def _get_edge_geometric_hash(self, edge_shape): try: from OCC.Core.BRep import BRep_Tool from OCC.Core.TopExp import TopExp - from OCC.Core.TopoDS import topods # Get vertices of the edge v1, v2 = TopExp.FirstVertex(edge_shape), TopExp.LastVertex(edge_shape) @@ -199,8 +215,16 @@ def _get_edge_geometric_hash(self, edge_shape): tolerance = 1e-6 # Round coordinates to handle floating point precision - x1, y1, z1 = round(pnt1.X() / tolerance) * tolerance, round(pnt1.Y() / tolerance) * tolerance, round(pnt1.Z() / tolerance) * tolerance - x2, y2, z2 = round(pnt2.X() / tolerance) * tolerance, round(pnt2.Y() / tolerance) * tolerance, round(pnt2.Z() / tolerance) * tolerance + x1, y1, z1 = ( + round(pnt1.X() / tolerance) * tolerance, + round(pnt1.Y() / tolerance) * tolerance, + round(pnt1.Z() / tolerance) * tolerance, + ) + x2, y2, z2 = ( + round(pnt2.X() / tolerance) * tolerance, + round(pnt2.Y() / tolerance) * tolerance, + round(pnt2.Z() / tolerance) * tolerance, + ) # Order vertices consistently (smaller coordinates first) if (x1, y1, z1) > (x2, y2, z2): @@ -212,14 +236,14 @@ def _get_edge_geometric_hash(self, edge_shape): # Fallback: use the edge pointer as hash (less reliable) return id(edge_shape) - @staticmethod - def union(solid1, solid2): + def union(self, solid2): """ - Create a union of two solids. + Create a union of this solid with another. + + Can be called as ``a.union(b)`` or ``Solid.union(a, b)``. Args: - solid1: First solid - solid2: Second solid + solid2: The other solid Returns: A new Solid that is the union of the inputs @@ -227,7 +251,7 @@ def union(solid1, solid2): from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Fuse from OCC.Core.TopoDS import TopoDS_Compound - fuse_op = BRepAlgoAPI_Fuse(solid1.topods_shape(), solid2.topods_shape()) + fuse_op = BRepAlgoAPI_Fuse(self.topods_shape(), solid2.topods_shape()) fuse_op.Build() if fuse_op.IsDone(): result_shape = fuse_op.Shape() @@ -236,6 +260,7 @@ def union(solid1, solid2): if isinstance(result_shape, TopoDS_Compound): # Check if compound contains only solids from .compound import Compound + compound = Compound(result_shape) if compound.num_solids() == 1: # Extract the single solid @@ -249,13 +274,13 @@ def union(solid1, solid2): return Solid(result_shape) raise RuntimeError("Boolean union operation failed") - @staticmethod - def difference(solid1, solid2): + def difference(self, solid2): """ - Create a difference of two solids (solid1 - solid2). + Create a difference of two solids (self - solid2). + + Can be called as ``a.difference(b)`` or ``Solid.difference(a, b)``. Args: - solid1: Base solid solid2: Solid to subtract Returns: @@ -264,7 +289,7 @@ def difference(solid1, solid2): from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut from OCC.Core.TopoDS import TopoDS_Compound - cut_op = BRepAlgoAPI_Cut(solid1.topods_shape(), solid2.topods_shape()) + cut_op = BRepAlgoAPI_Cut(self.topods_shape(), solid2.topods_shape()) cut_op.Build() if cut_op.IsDone(): result_shape = cut_op.Shape() @@ -273,6 +298,7 @@ def difference(solid1, solid2): if isinstance(result_shape, TopoDS_Compound): # Check if compound contains only solids from .compound import Compound + compound = Compound(result_shape) if compound.num_solids() == 1: # Extract the single solid @@ -286,14 +312,14 @@ def difference(solid1, solid2): return Solid(result_shape) raise RuntimeError("Boolean difference operation failed") - @staticmethod - def intersection(solid1, solid2): + def intersection(self, solid2): """ - Create an intersection of two solids. + Create an intersection of this solid with another. + + Can be called as ``a.intersection(b)`` or ``Solid.intersection(a, b)``. Args: - solid1: First solid - solid2: Second solid + solid2: The other solid Returns: A new Solid that is the intersection of the inputs @@ -301,7 +327,7 @@ def intersection(solid1, solid2): from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Common from OCC.Core.TopoDS import TopoDS_Compound - common_op = BRepAlgoAPI_Common(solid1.topods_shape(), solid2.topods_shape()) + common_op = BRepAlgoAPI_Common(self.topods_shape(), solid2.topods_shape()) common_op.Build() if common_op.IsDone(): result_shape = common_op.Shape() @@ -310,6 +336,7 @@ def intersection(solid1, solid2): if isinstance(result_shape, TopoDS_Compound): # Check if compound contains only solids from .compound import Compound + compound = Compound(result_shape) if compound.num_solids() == 1: # Extract the single solid @@ -323,21 +350,19 @@ def intersection(solid1, solid2): return Solid(result_shape) raise RuntimeError("Boolean intersection operation failed") - @staticmethod - def subtraction(solid1, solid2): + def subtraction(self, solid2): """ - Create a subtraction of two solids (solid1 - solid2). + Create a subtraction of two solids (self - solid2). This is an alias for the difference method. Args: - solid1: Base solid solid2: Solid to subtract Returns: A new Solid that is the difference of the inputs """ - return Solid.difference(solid1, solid2) + return self.difference(solid2) # # Factory methods for creating standard solids @@ -366,7 +391,7 @@ def make_box(dx, dy, dz, corner_point=None): # Handle corner point if corner_point is not None: - if hasattr(corner_point, 'X'): # It's a gp_Pnt + if hasattr(corner_point, "X"): # It's a gp_Pnt box = BRepPrimAPI_MakeBox(corner_point, float(dx), float(dy), float(dz)).Solid() else: # It's an array-like [x, y, z] @@ -406,9 +431,10 @@ def make_cylinder(radius, height, center=None, axis=None, angle=None): Returns: Solid representing the cylinder """ - from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt import math + from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + # Validate parameters if radius <= 0: raise ValueError("Cylinder radius must be positive") @@ -428,22 +454,26 @@ def make_cylinder(radius, height, center=None, axis=None, angle=None): raise ValueError("Axis must have 3 components") # Normalize axis - axis_mag = math.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2) + axis_mag = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2) if axis_mag < 1e-10: raise ValueError("Axis vector cannot be zero") - axis_normalized = [axis[0]/axis_mag, axis[1]/axis_mag, axis[2]/axis_mag] + axis_normalized = [axis[0] / axis_mag, axis[1] / axis_mag, axis[2] / axis_mag] # Create coordinate system base = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) - direction = gp_Dir(float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2])) + direction = gp_Dir( + float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2]) + ) placement = gp_Ax2(base, direction) # Create the cylinder if angle is not None: if angle <= 0 or angle > 2 * math.pi: raise ValueError("Angle must be between 0 and 2π") - cylinder = BRepPrimAPI_MakeCylinder(placement, float(radius), float(height), float(angle)).Solid() + cylinder = BRepPrimAPI_MakeCylinder( + placement, float(radius), float(height), float(angle) + ).Solid() else: cylinder = BRepPrimAPI_MakeCylinder(placement, float(radius), float(height)).Solid() @@ -464,9 +494,10 @@ def make_sphere(radius, center=None, angle1=None, angle2=None, angle3=None): Returns: Solid representing the sphere """ - from OCC.Core.gp import gp_Pnt import math + from OCC.Core.gp import gp_Pnt + # Validate parameters if radius <= 0: raise ValueError("Sphere radius must be positive") @@ -500,7 +531,9 @@ def make_sphere(radius, center=None, angle1=None, angle2=None, angle3=None): if angle3 <= 0 or angle3 > math.pi: raise ValueError("angle3 must be between 0 and π") - sphere = BRepPrimAPI_MakeSphere(position, float(radius), float(angle1), float(angle2), float(angle3)).Solid() + sphere = BRepPrimAPI_MakeSphere( + position, float(radius), float(angle1), float(angle2), float(angle3) + ).Solid() else: sphere = BRepPrimAPI_MakeSphere(position, float(radius)).Solid() @@ -522,9 +555,10 @@ def make_cone(radius1, radius2, height, center=None, axis=None, angle=None): Returns: Solid representing the cone """ - from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt import math + from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + # Validate parameters if radius1 < 0 or radius2 < 0: raise ValueError("Cone radii must be non-negative") @@ -546,29 +580,37 @@ def make_cone(radius1, radius2, height, center=None, axis=None, angle=None): raise ValueError("Axis must have 3 components") # Normalize axis - axis_mag = math.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2) + axis_mag = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2) if axis_mag < 1e-10: raise ValueError("Axis vector cannot be zero") - axis_normalized = [axis[0]/axis_mag, axis[1]/axis_mag, axis[2]/axis_mag] + axis_normalized = [axis[0] / axis_mag, axis[1] / axis_mag, axis[2] / axis_mag] # Create coordinate system base = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) - direction = gp_Dir(float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2])) + direction = gp_Dir( + float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2]) + ) placement = gp_Ax2(base, direction) # Create the cone if angle is not None: if angle <= 0 or angle > 2 * math.pi: raise ValueError("Angle must be between 0 and 2π") - cone = BRepPrimAPI_MakeCone(placement, float(radius1), float(radius2), float(height), float(angle)).Solid() + cone = BRepPrimAPI_MakeCone( + placement, float(radius1), float(radius2), float(height), float(angle) + ).Solid() else: - cone = BRepPrimAPI_MakeCone(placement, float(radius1), float(radius2), float(height)).Solid() + cone = BRepPrimAPI_MakeCone( + placement, float(radius1), float(radius2), float(height) + ).Solid() return Solid(cone) @staticmethod - def make_torus(major_radius, minor_radius, center=None, axis=None, angle1=None, angle2=None, angle=None): + def make_torus( + major_radius, minor_radius, center=None, axis=None, angle1=None, angle2=None, angle=None + ): """ Create a torus or partial torus. @@ -584,9 +626,10 @@ def make_torus(major_radius, minor_radius, center=None, axis=None, angle1=None, Returns: Solid representing the torus """ - from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt import math + from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt + # Validate parameters if major_radius <= 0: raise ValueError("Major radius must be positive") @@ -608,15 +651,17 @@ def make_torus(major_radius, minor_radius, center=None, axis=None, angle1=None, raise ValueError("Axis must have 3 components") # Normalize axis - axis_mag = math.sqrt(axis[0]**2 + axis[1]**2 + axis[2]**2) + axis_mag = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2) if axis_mag < 1e-10: raise ValueError("Axis vector cannot be zero") - axis_normalized = [axis[0]/axis_mag, axis[1]/axis_mag, axis[2]/axis_mag] + axis_normalized = [axis[0] / axis_mag, axis[1] / axis_mag, axis[2] / axis_mag] # Create coordinate system base = gp_Pnt(float(center[0]), float(center[1]), float(center[2])) - direction = gp_Dir(float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2])) + direction = gp_Dir( + float(axis_normalized[0]), float(axis_normalized[1]), float(axis_normalized[2]) + ) placement = gp_Ax2(base, direction) # Create the torus @@ -630,19 +675,34 @@ def make_torus(major_radius, minor_radius, center=None, axis=None, angle1=None, if angle is not None: if angle <= 0 or angle > 2 * math.pi: raise ValueError("Meridian angle must be between 0 and 2π") - torus = BRepPrimAPI_MakeTorus(placement, float(major_radius), float(minor_radius), - float(angle1), float(angle2), float(angle)).Solid() + torus = BRepPrimAPI_MakeTorus( + placement, + float(major_radius), + float(minor_radius), + float(angle1), + float(angle2), + float(angle), + ).Solid() else: - torus = BRepPrimAPI_MakeTorus(placement, float(major_radius), float(minor_radius), - float(angle1), float(angle2)).Solid() + torus = BRepPrimAPI_MakeTorus( + placement, + float(major_radius), + float(minor_radius), + float(angle1), + float(angle2), + ).Solid() elif angle is not None: # Partial torus with meridian angle only if angle <= 0 or angle > 2 * math.pi: raise ValueError("Meridian angle must be between 0 and 2π") - torus = BRepPrimAPI_MakeTorus(placement, float(major_radius), float(minor_radius), float(angle)).Solid() + torus = BRepPrimAPI_MakeTorus( + placement, float(major_radius), float(minor_radius), float(angle) + ).Solid() else: # Full torus - torus = BRepPrimAPI_MakeTorus(placement, float(major_radius), float(minor_radius)).Solid() + torus = BRepPrimAPI_MakeTorus( + placement, float(major_radius), float(minor_radius) + ).Solid() return Solid(torus) @@ -650,33 +710,32 @@ def make_torus(major_radius, minor_radius, center=None, axis=None, angle1=None, def make_from_shape(shape): """ Create a solid from a shape using PyOCC methods. - + Args: shape: Shape object to convert to solid - + Returns: Solid object or None if conversion fails """ - from OCC.Core.TopAbs import TopAbs_SOLID, TopAbs_SHELL, TopAbs_FACE + from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeShell, BRepBuilderAPI_MakeSolid + from OCC.Core.TopAbs import TopAbs_FACE, TopAbs_SHELL, TopAbs_SOLID from OCC.Core.TopExp import TopExp_Explorer from OCC.Core.TopoDS import topods - from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeSolid, BRepBuilderAPI_MakeShell - from .shell import Shell - + # Get the underlying OCC shape occ_shape = shape.topods_shape() - + # Check if it's already a solid if occ_shape.ShapeType() == TopAbs_SOLID: return Solid(occ_shape) - + # Try to create solid from shell if occ_shape.ShapeType() == TopAbs_SHELL: shell = topods.Shell(occ_shape) solid_maker = BRepBuilderAPI_MakeSolid(shell) if solid_maker.IsDone(): return Solid(solid_maker.Solid()) - + # Try to create solid from compound of faces faces = [] explorer = TopExp_Explorer(occ_shape, TopAbs_FACE) @@ -684,80 +743,82 @@ def make_from_shape(shape): face = explorer.Current() faces.append(face) explorer.Next() - + if faces: # Create shell from faces shell_maker = BRepBuilderAPI_MakeShell() for face in faces: shell_maker.Add(face) - + if shell_maker.IsDone(): shell = shell_maker.Shell() # Create solid from shell solid_maker = BRepBuilderAPI_MakeSolid(shell) if solid_maker.IsDone(): return Solid(solid_maker.Solid()) - + return None @staticmethod def make_from_shell(shell): """ Create a solid from a shell using PyOCC methods. - + Args: shell: Shell object to convert to solid - + Returns: Solid object or None if conversion fails """ from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeSolid - + + from .shell import Shell + if isinstance(shell, Shell): occ_shell = shell.topods_shape() else: occ_shell = shell - + solid_maker = BRepBuilderAPI_MakeSolid(occ_shell) if solid_maker.IsDone(): return Solid(solid_maker.Solid()) - + return None @staticmethod def make_from_faces(faces): """ Create a solid from a list of faces using PyOCC methods. - + Args: faces: List of Face objects or TopoDS_Face objects - + Returns: Solid object or None if creation fails """ from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeShell, BRepBuilderAPI_MakeSolid - + if not faces: return None - + # Create shell from faces shell_maker = BRepBuilderAPI_MakeShell() - + for face in faces: - if hasattr(face, 'topods_shape'): + if hasattr(face, "topods_shape"): # It's a PyOCC Face object shell_maker.Add(face.topods_shape()) else: # It's already a TopoDS_Face shell_maker.Add(face) - + if shell_maker.IsDone(): shell = shell_maker.Shell() # Create solid from shell solid_maker = BRepBuilderAPI_MakeSolid(shell) if solid_maker.IsDone(): return Solid(solid_maker.Solid()) - + return None def find_closest_point_data(self, datum): @@ -771,11 +832,10 @@ def find_closest_point_data(self, datum): datum: Point to find closest point to (numpy array or list of 3 coordinates) Returns: - Tuple of (closest_point, closest_face, distance, uv_parameters): + Tuple of (closest_point, closest_face, distance): - closest_point: numpy array [x, y, z] of the closest point - closest_face: Face object containing the closest point - distance: distance from datum to closest point - - uv_parameters: UV coordinates on the closest face """ try: # Validate input @@ -785,9 +845,8 @@ def find_closest_point_data(self, datum): datum_array = np.array(datum, dtype=float) # Search all faces to find closest point - min_distance = float('inf') + min_distance = float("inf") closest_point = None - closest_uv = None closest_face = None # Iterate through all faces @@ -795,7 +854,7 @@ def find_closest_point_data(self, datum): if not faces: # No faces found, return the input point as fallback logging.warning("No faces found in solid") - return datum_array, None, 0.0, None + return datum_array, None, 0.0 for face in faces: try: @@ -804,7 +863,6 @@ def find_closest_point_data(self, datum): if distance < min_distance: min_distance = distance closest_point = point - closest_uv = uv closest_face = face # Early termination if we find a point very close @@ -820,14 +878,14 @@ def find_closest_point_data(self, datum): if closest_face is None or closest_point is None: # Fallback: return the input point logging.warning("Could not find closest point on any face") - return datum_array, None, 0.0, None + return datum_array, None, 0.0 - return closest_point, closest_face, min_distance, closest_uv + return closest_point, closest_face, min_distance except Exception as e: logging.error(f"Error in find_closest_point_data: {e}") # Fallback: return the input point - return np.array(datum, dtype=float), None, 0.0, None + return np.array(datum, dtype=float), None, 0.0 def _detect_shape_type(self): """ @@ -869,14 +927,12 @@ def _detect_shape_type(self): # Cylinder detection: 2 planar faces (ends) + 1 cylindrical surface elif n_faces == 3: - if (face_types.get("cylinder", 0) == 1 and - face_types.get("plane", 0) == 2): + if face_types.get("cylinder", 0) == 1 and face_types.get("plane", 0) == 2: return "cylinder" # Cone detection: 1 planar face (base) + 1 conical surface elif n_faces == 2: - if (face_types.get("cone", 0) == 1 and - face_types.get("plane", 0) == 1): + if face_types.get("cone", 0) == 1 and face_types.get("plane", 0) == 1: return "cone" # Torus detection: 1 toroidal face @@ -929,28 +985,28 @@ def __str__(self): volume = self.volume() has_voids = "with voids" if self.has_voids() else "solid" return f"Solid({shape_type}, {n_faces} faces, volume={volume:.3f}, {has_voids})" - except: + except Exception: return "Solid(invalid)" def fuse(self, other): """ Fuse this solid with another solid. - + Args: other: Another Solid object - + Returns: A new Solid representing the union """ return Solid.union(self, other) - + def clean(self): """ Clean the solid by removing redundant geometry. - + Returns: A new cleaned Solid """ # For now, return self as cleaning is not implemented # This is a placeholder for the interface expected by Workplane - return self \ No newline at end of file + return self diff --git a/src/pyocc/uvgrid.py b/src/pyocc/uvgrid.py index b8cca64..0c1b66e 100644 --- a/src/pyocc/uvgrid.py +++ b/src/pyocc/uvgrid.py @@ -4,9 +4,11 @@ This module provides functions for creating parametric UV grids on faces and edges, following a similar approach to occwl but adapted for pyocc. """ + import numpy as np -from .face import Face + from .edge import Edge +from .face import Face def _uvgrid_reverse_u(grid): @@ -56,7 +58,7 @@ def uvgrid(face, num_u=10, num_v=10, uvs=False, method="point", reverse_order_wi if uvs: return None, uv_values return None - except: + except Exception: # Can't get a surface for this face if uvs: return None, uv_values @@ -73,7 +75,7 @@ def uvgrid(face, num_u=10, num_v=10, uvs=False, method="point", reverse_order_wi try: val = fn(uv) uvgrid_data.append(val) - except: + except Exception: # If evaluation fails, append None or a default value if method == "point": uvgrid_data.append(np.array([0.0, 0.0, 0.0])) @@ -90,7 +92,7 @@ def uvgrid(face, num_u=10, num_v=10, uvs=False, method="point", reverse_order_wi if face.reversed(): uvgrid_array = _uvgrid_reverse_u(uvgrid_array) uv_values = _uvgrid_reverse_u(uv_values) - except: + except Exception: # If we can't determine if face is reversed, continue without reversal pass @@ -129,7 +131,7 @@ def ugrid(edge, num_u=10, us=False, method="point", reverse_order_with_edge=True if us: return None, u_values return None - except: + except Exception: # Can't get a curve for this edge if us: return None, u_values @@ -149,7 +151,7 @@ def ugrid(edge, num_u=10, us=False, method="point", reverse_order_with_edge=True try: val = fn(u) ugrid_data.append(val) - except: + except Exception: # If evaluation fails, append None or a default value if method == "point": ugrid_data.append(np.array([0.0, 0.0, 0.0])) @@ -166,7 +168,7 @@ def ugrid(edge, num_u=10, us=False, method="point", reverse_order_with_edge=True if edge.reversed(): ugrid_array = _ugrid_reverse_u(ugrid_array) u_values = u_values[::-1] - except: + except Exception: # If we can't determine if edge is reversed, continue without reversal pass @@ -235,3 +237,112 @@ def ugrid_tangents(edge, num_u=10, **kwargs): np.ndarray: 1D array of tangent vectors [num_u, 3] """ return ugrid(edge, num_u, method="tangent", **kwargs) + + +class UniformGrid: + """ + A uniform UV sampling grid over a face's parameter domain. + + Samples the face on a regular ``u_count`` x ``v_count`` lattice spanning the + face's UV bounds, and exposes the resulting points, parameters, and unit + normals. A count of 1 along an axis samples the midpoint of that axis. + """ + + def __init__(self, face, u_count=10, v_count=10): + if not isinstance(face, Face): + raise TypeError("Expected a Face object") + if u_count < 1 or v_count < 1: + raise ValueError("u_count and v_count must be >= 1") + + self.face = face + self.u_count = int(u_count) + self.v_count = int(v_count) + + u_min, u_max, v_min, v_max = face.uv_bounds() + self._u_params = self._sample_axis(u_min, u_max, self.u_count) + self._v_params = self._sample_axis(v_min, v_max, self.v_count) + + @staticmethod + def _sample_axis(lo, hi, count): + """Evenly sample ``count`` parameters across [lo, hi] (midpoint if count == 1).""" + if count == 1: + return np.array([0.5 * (lo + hi)]) + return np.linspace(lo, hi, count) + + def _iter_uv(self): + for u in self._u_params: + for v in self._v_params: + yield float(u), float(v) + + def get_parameters(self): + """Return ``(u_params, v_params)`` as 1D arrays of length u_count and v_count.""" + return self._u_params, self._v_params + + def get_points(self): + """Return a ``(u_count * v_count, 3)`` array of 3D surface points.""" + points = [self.face.point(np.array([u, v])) for u, v in self._iter_uv()] + return np.asarray(points, dtype=float).reshape(-1, 3) + + def get_normals(self): + """Return a ``(u_count * v_count, 3)`` array of unit surface normals.""" + normals = [] + for u, v in self._iter_uv(): + normal = np.asarray(self.face.normal(np.array([u, v])), dtype=float) + length = np.linalg.norm(normal) + if length > 0: + normal = normal / length + normals.append(normal) + return np.asarray(normals, dtype=float).reshape(-1, 3) + + +def sample_face_uniformly(face, num_u=10, num_v=10): + """ + Uniformly sample a face on a ``num_u`` x ``num_v`` UV lattice. + + Returns: + dict with keys: + 'points' -> (num_u * num_v, 3) array of 3D points + 'normals' -> (num_u * num_v, 3) array of unit normals + 'parameters' -> (num_u * num_v, 2) array of [u, v] parameter pairs + """ + grid = UniformGrid(face, u_count=num_u, v_count=num_v) + u_params, v_params = grid.get_parameters() + parameters = np.array([[u, v] for u in u_params for v in v_params], dtype=float) + return { + "points": grid.get_points(), + "normals": grid.get_normals(), + "parameters": parameters, + } + + +def sample_edge_uniformly(edge, num_points=10): + """ + Uniformly sample an edge at ``num_points`` parameters in ascending order. + + Returns: + dict with keys: + 'points' -> (num_points, 3) array of 3D points + 'tangents' -> (num_points, 3) array of unit tangent vectors + 'parameters' -> (num_points,) array of ascending curve parameters + """ + if not isinstance(edge, Edge): + raise TypeError("Expected an Edge object") + + points, parameters = ugrid( + edge, num_points, us=True, method="point", reverse_order_with_edge=False + ) + tangents = ugrid(edge, num_points, method="tangent", reverse_order_with_edge=False) + + points = np.asarray(points, dtype=float).reshape(-1, 3) + tangents = np.asarray(tangents, dtype=float).reshape(-1, 3) + + # Normalize tangent vectors to unit length + lengths = np.linalg.norm(tangents, axis=1, keepdims=True) + lengths[lengths == 0] = 1.0 + tangents = tangents / lengths + + return { + "points": points, + "tangents": tangents, + "parameters": np.asarray(parameters, dtype=float), + } diff --git a/src/pyocc/vertex.py b/src/pyocc/vertex.py index b62bdac..17edb4b 100644 --- a/src/pyocc/vertex.py +++ b/src/pyocc/vertex.py @@ -5,13 +5,15 @@ """ import numpy as np -from OCC.Core.TopoDS import TopoDS_Vertex, TopoDS_Shape -from OCC.Core.BRep import BRep_Tool, BRep_Builder -from OCC.Core.gp import gp_Pnt +from OCC.Core.BRep import BRep_Builder, BRep_Tool from OCC.Core.BRepExtrema import BRepExtrema_DistShapeShape +from OCC.Core.gp import gp_Pnt +from OCC.Core.TopoDS import TopoDS_Vertex + +from .shape import Shape -class Vertex: +class Vertex(Shape): """ A vertex (point) in 3D space. @@ -121,13 +123,13 @@ def find_closest_point_data(self, datum): datum: Point to find closest point to (numpy array or list) Returns: - Dictionary containing: - - point: Point on vertex (same as vertex point) - - parameter: None (no parameter for vertex) - - distance: Distance to the vertex point + Tuple of (point, parameter, distance): + - point: numpy array [x, y, z] of the vertex location + - parameter: None (a vertex has no parameter) + - distance: distance from datum to the vertex point """ point = self.point() if point is None or len(point) != 3: raise ValueError(f"Vertex point is invalid or not 3D: {point}") dist = np.linalg.norm(np.array(datum) - point) - return {"point": point.tolist(), "parameter": None, "distance": dist} + return point, None, dist diff --git a/src/pyocc/viewer.py b/src/pyocc/viewer.py index 4eceea5..b10d16b 100644 --- a/src/pyocc/viewer.py +++ b/src/pyocc/viewer.py @@ -7,10 +7,10 @@ """ from datetime import datetime -from typing import Any, Callable, List, Optional, Tuple, Union +from typing import Any, List, Optional, Tuple import numpy as np -from OCC.Core.AIS import AIS_Line, AIS_Point, AIS_Shaded, AIS_WireFrame, AIS_Axis +from OCC.Core.AIS import AIS_Line, AIS_Point, AIS_Shaded, AIS_WireFrame from OCC.Core.Aspect import ( Aspect_TOL_DASH, Aspect_TOL_DOT, @@ -24,41 +24,33 @@ Aspect_TypeOfLine, Aspect_TypeOfMarker, ) -from OCC.Core.Graphic3d import Graphic3d_TOSM_VERTEX, Graphic3d_TOSM_FACET, Graphic3d_TOSM_FRAGMENT -from OCC.Core.gp import gp_Ax1, gp_Dir, gp_Pnt from OCC.Core.Geom import Geom_CartesianPoint, Geom_Line +from OCC.Core.gp import gp_Dir, gp_Pnt +from OCC.Core.Graphic3d import Graphic3d_TOSM_FACET, Graphic3d_TOSM_FRAGMENT, Graphic3d_TOSM_VERTEX from OCC.Core.Prs3d import Prs3d_LineAspect, Prs3d_PointAspect from OCC.Core.Quantity import Quantity_Color, Quantity_TOC_RGB, Quantity_TypeOfColor -from OCC.Core.TopAbs import ( - TopAbs_EDGE, - TopAbs_FACE, - TopAbs_SHELL, - TopAbs_SOLID, - TopAbs_VERTEX, -) +from OCC.Core.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL, TopAbs_SOLID, TopAbs_VERTEX from OCC.Core.TopoDS import ( + TopoDS_Compound, TopoDS_Edge, TopoDS_Face, TopoDS_Shell, TopoDS_Solid, TopoDS_Vertex, - TopoDS_Compound, ) from OCC.Core.V3d import V3d_DirectionalLight +from OCC.Display.OCCViewer import Viewer3d, get_color_from_name from OCC.Display.SimpleGui import init_display -from OCC.Display.OCCViewer import get_color_from_name -from OCC.Display.OCCViewer import Viewer3d # Import pyocc shapes try: + from pyocc.compound import Compound from pyocc.edge import Edge from pyocc.face import Face - from pyocc.wire import Wire + from pyocc.geometry import geom_utils + from pyocc.shell import Shell from pyocc.solid import Solid from pyocc.vertex import Vertex - from pyocc.compound import Compound - from pyocc.shell import Shell - from pyocc.geometry import geom_utils except ImportError: # Fallback for when pyocc modules aren't available pass @@ -118,9 +110,9 @@ def display(self, shape, update=False, color=None, transparency=0.0): """ self._validate_display() assert self._display is not None # Type guard for linter - + # Convert pyocc shapes to TopoDS shapes - if hasattr(shape, 'topods_shape'): + if hasattr(shape, "topods_shape"): shape = shape.topods_shape() if color and not isinstance(color, (str, tuple)): @@ -128,7 +120,10 @@ def display(self, shape, update=False, color=None, transparency=0.0): if isinstance(color, (tuple, list)): assert len(color) == 3, "Expected a 3-tuple/list when color is specified as RGB" color = Quantity_Color( - float(color[0]), float(color[1]), float(color[2]), Quantity_TypeOfColor(Quantity_TOC_RGB) + float(color[0]), + float(color[1]), + float(color[2]), + Quantity_TypeOfColor(Quantity_TOC_RGB), ) return self._display.DisplayShape( shape, update=update, color=color, transparency=transparency @@ -162,7 +157,7 @@ def display_points(self, pts, color=None, scale=10, marker="ball"): """ self._validate_display() assert self._display is not None # Type guard for linter - + if color is None: color = (0, 0, 0) if marker == "point": @@ -183,13 +178,18 @@ def display_points(self, pts, color=None, scale=10, marker="ball"): point_entities = [] for idx in range(pts.shape[0]): if isinstance(color, tuple): - quantity_color = Quantity_Color(color[0], color[1], color[2], Quantity_TypeOfColor(Quantity_TOC_RGB)) + quantity_color = Quantity_Color( + color[0], color[1], color[2], Quantity_TypeOfColor(Quantity_TOC_RGB) + ) elif isinstance(color, np.ndarray): assert ( pts.shape[0] == color.shape[0] ), "pts and color must match in size (#points x 3)" quantity_color = Quantity_Color( - color[idx, 0], color[idx, 1], color[idx, 2], Quantity_TypeOfColor(Quantity_TOC_RGB) + color[idx, 0], + color[idx, 1], + color[idx, 2], + Quantity_TypeOfColor(Quantity_TOC_RGB), ) elif isinstance(color, str): quantity_color = get_color_from_name(color) @@ -204,7 +204,12 @@ def display_points(self, pts, color=None, scale=10, marker="ball"): return point_entities def display_lines( - self, origins, directions, color=None, thickness=1, style="solid", + self, + origins, + directions, + color=None, + thickness=1, + style="solid", ): """ Display a set of lines @@ -218,7 +223,7 @@ def display_lines( """ self._validate_display() assert self._display is not None # Type guard for linter - + if color is None: color = (0, 0, 0) if style == "solid": @@ -239,23 +244,30 @@ def display_lines( line_entities = [] for idx in range(origins.shape[0]): if isinstance(color, tuple): - quantity_color = Quantity_Color(color[0], color[1], color[2], Quantity_TypeOfColor(Quantity_TOC_RGB)) + quantity_color = Quantity_Color( + color[0], color[1], color[2], Quantity_TypeOfColor(Quantity_TOC_RGB) + ) elif isinstance(color, np.ndarray): assert ( origins.shape[0] == color.shape[0] ), "origins and color must match in size (#lines x 3)" quantity_color = Quantity_Color( - color[idx, 0], color[idx, 1], color[idx, 2], Quantity_TypeOfColor(Quantity_TOC_RGB) + color[idx, 0], + color[idx, 1], + color[idx, 2], + Quantity_TypeOfColor(Quantity_TOC_RGB), ) elif isinstance(color, str): quantity_color = get_color_from_name(color) start_pnt = gp_Pnt(origins[idx, 0], origins[idx, 1], origins[idx, 2]) - end_pnt = gp_Pnt( + gp_Pnt( origins[idx, 0] + directions[idx, 0], origins[idx, 1] + directions[idx, 1], origins[idx, 2] + directions[idx, 2], ) - geom_line = Geom_Line(start_pnt, gp_Dir(directions[idx, 0], directions[idx, 1], directions[idx, 2])) + geom_line = Geom_Line( + start_pnt, gp_Dir(directions[idx, 0], directions[idx, 1], directions[idx, 2]) + ) ais_line = AIS_Line(geom_line) attr = ais_line.Attributes() asp = Prs3d_LineAspect(quantity_color, line_type, thickness) @@ -502,6 +514,72 @@ def add_directional_light(self, direction, color, intensity=500.0): self._display.Viewer.AddLight(dir_light) self._display.Viewer.SetLightOn() + def add_ambient_light(self, intensity=0.3): + """ + Add an ambient light to the scene. + + Args: + intensity (float, optional): Light intensity. Defaults to 0.3. + """ + self._validate_display() + assert self._display is not None # Type guard for linter + from OCC.Core.V3d import V3d_AmbientLight + + ambient = V3d_AmbientLight() + ambient.SetIntensity(intensity) + self._display.Viewer.AddLight(ambient) + self._display.Viewer.SetLightOn() + + def clear_lights(self): + """Turn off all currently active lights in the scene.""" + self._validate_display() + assert self._display is not None # Type guard for linter + self._display.Viewer.SetLightOff() + + def clear_scene(self): + """Erase all displayed shapes from the scene.""" + self._validate_display() + assert self._display is not None # Type guard for linter + self._display.EraseAll() + + def set_view_direction(self, direction): + """ + Set the camera projection direction. + + Args: + direction (gp_Dir): Direction the camera looks along. + """ + self._validate_display() + assert self._display is not None # Type guard for linter + self._display.View.SetProj(direction.X(), direction.Y(), direction.Z()) + + def set_view_orientation(self, orientation): + """ + Set a standard view orientation. + + Args: + orientation (V3d_TypeOfOrientation): A predefined orientation. + """ + self._validate_display() + assert self._display is not None # Type guard for linter + self._display.View.SetProj(orientation) + + def set_camera(self, eye, target, up): + """ + Position the camera explicitly. + + Args: + eye: Camera position [x, y, z]. + target: Point the camera looks at [x, y, z]. + up: Camera up vector [x, y, z]. + """ + self._validate_display() + assert self._display is not None # Type guard for linter + view = self._display.View + view.SetEye(float(eye[0]), float(eye[1]), float(eye[2])) + view.SetAt(float(target[0]), float(target[1]), float(target[2])) + view.SetUp(float(up[0]), float(up[1]), float(up[2])) + class Viewer(_BaseViewer): """ @@ -671,6 +749,8 @@ def __init__( axes: Optional[bool] = True, background_top_color: Optional[List[int]] = [206, 215, 222], background_bottom_color: Optional[List[int]] = [128, 128, 128], + width: Optional[int] = None, + height: Optional[int] = None, ): """ Construct the OffscreenRenderer @@ -680,20 +760,30 @@ def __init__( axes (Optional[bool]], optional): Show arrows for coordinate axes. Defaults to True. background_top_color (Optional[List[int]], optional): Background color at the top. Defaults to [206, 215, 222]. background_bottom_color (Optional[List[int]], optional): Background color at the bottom. Defaults to [128, 128, 128]. + width (Optional[int], optional): Render width in pixels; overrides size[0] when given. + height (Optional[int], optional): Render height in pixels; overrides size[1] when given. """ super().__init__() + + # Allow width/height to be supplied instead of (or alongside) the size tuple. + if width is not None or height is not None: + base = size if size is not None else (1024, 768) + size = ( + width if width is not None else base[0], + height if height is not None else base[1], + ) try: self._display = Viewer3d() self._display.Create() except Exception as e: raise ViewerException(f"Failed to create offscreen display: {e}") - + if self._display is None: raise ViewerException("Offscreen display creation failed") - + if size is None: raise ViewerException("Size parameter cannot be None") - + if axes: self.show_axes() else: diff --git a/src/pyocc/wire.py b/src/pyocc/wire.py index b23a953..aed3746 100644 --- a/src/pyocc/wire.py +++ b/src/pyocc/wire.py @@ -7,21 +7,18 @@ import logging import numpy as np -from OCC.Core.BRep import BRep_Tool from OCC.Core.BRepAdaptor import BRepAdaptor_CompCurve from OCC.Core.BRepGProp import brepgprop from OCC.Core.BRepTools import BRepTools_WireExplorer from OCC.Core.GProp import GProp_GProps from OCC.Core.TopoDS import TopoDS_Wire -from OCC.Core.TopAbs import TopAbs_EDGE -from .base import EdgeContainerMixin, VertexContainerMixin -from .geometry.interval import Interval -# Remove circular import -# from .shape import Shape +from .base import VertexContainerMixin +from .geometry.interval import Interval +from .shape import Shape -class Wire(EdgeContainerMixin, VertexContainerMixin): +class Wire(Shape, VertexContainerMixin): """ A wire representing a sequence of connected edges. @@ -48,7 +45,6 @@ def make_from_edges(edges): RuntimeError: If wire creation fails due to connectivity issues """ from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeWire - from OCC.Core.ShapeAnalysis import ShapeAnalysis_Wire if not edges: raise ValueError("Cannot create wire from empty edge list") @@ -321,10 +317,11 @@ def is_seam(self, face=None): try: if face is not None: # Check if this wire appears as a seam on the specific face - from OCC.Core.TopExp import TopExp_Explorer - from OCC.Core.TopoDS import topods from collections import defaultdict + from OCC.Core.TopAbs import TopAbs_EDGE + from OCC.Core.TopExp import TopExp_Explorer + from OCC.Core.TopoDS import topods # Count how many times each edge appears in the face edge_count = defaultdict(int) @@ -366,7 +363,7 @@ def is_seam(self, face=None): try: curve_type = edge.curve_type() curve_types.append(curve_type.lower()) - except (AttributeError, TypeError, ValueError) as e: + except (AttributeError, TypeError, ValueError): continue # Seams are often composed of simple curves @@ -496,16 +493,15 @@ def find_closest_point_data(self, datum): datum_array = np.array(datum, dtype=float) # Use the composite curve adaptor for precise projection + from OCC.Core.BRep import BRep_Tool from OCC.Core.GeomAPI import GeomAPI_ProjectPointOnCurve from OCC.Core.gp import gp_Pnt - from OCC.Core.BRep import BRep_Tool - from OCC.Core.TopoDS import TopoDS_Edge, topods # Create a gp_Pnt for the datum datum_pnt = gp_Pnt(float(datum_array[0]), float(datum_array[1]), float(datum_array[2])) # Project point onto the composite curve - projector = GeomAPI_ProjectPointOnCurve() + GeomAPI_ProjectPointOnCurve() # We need to use individual edges instead of the composite curve try: @@ -553,7 +549,6 @@ def find_closest_point_data(self, datum): min_dist = float("inf") closest_point = None closest_param = None - closest_edge_index = -1 edges = self.ordered_edges() if not edges: @@ -570,7 +565,7 @@ def find_closest_point_data(self, datum): edge_length = edge.length() edge_lengths.append(edge_length) total_length += edge_length - except: + except Exception: edge_lengths.append(0.0) # Find closest point on each edge @@ -581,7 +576,6 @@ def find_closest_point_data(self, datum): if dist < min_dist: min_dist = dist closest_point = point - closest_edge_index = i # Convert edge parameter to wire parameter if total_length > 0: @@ -619,5 +613,5 @@ def __str__(self): is_closed_val = self.is_closed() closed_str = "closed" if is_closed_val else "open" return f"Wire({n_edges} edges, length={length:.3f}, {closed_str})" - except: + except Exception: return "Wire(invalid)" diff --git a/tests/__init__.py b/tests/__init__.py index 2b2a06d..0015197 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,4 +2,4 @@ Tests for pyocc. This package contains unit tests for the pyocc library. -""" \ No newline at end of file +""" diff --git a/tests/src/__init__.py b/tests/src/__init__.py index bf44611..39f4095 100644 --- a/tests/src/__init__.py +++ b/tests/src/__init__.py @@ -2,4 +2,4 @@ Test source modules for pyocc. This package contains the source code for pyocc tests. -""" \ No newline at end of file +""" diff --git a/tests/src/pyocc/__init__.py b/tests/src/pyocc/__init__.py index 6e1cada..233b8cb 100644 --- a/tests/src/pyocc/__init__.py +++ b/tests/src/pyocc/__init__.py @@ -2,4 +2,4 @@ Pyocc test modules. This package contains unit tests for pyocc functionality. -""" \ No newline at end of file +""" diff --git a/tests/src/pyocc/analysis/__init__.py b/tests/src/pyocc/analysis/__init__.py index b2b8bad..290ccf5 100644 --- a/tests/src/pyocc/analysis/__init__.py +++ b/tests/src/pyocc/analysis/__init__.py @@ -2,4 +2,4 @@ Analysis test modules for pyocc. This package contains unit tests for pyocc analysis functionality. -""" \ No newline at end of file +""" diff --git a/tests/src/pyocc/arc_length_param_finder_test.py b/tests/src/pyocc/arc_length_param_finder_test.py index faf5b67..2087cea 100644 --- a/tests/src/pyocc/arc_length_param_finder_test.py +++ b/tests/src/pyocc/arc_length_param_finder_test.py @@ -1,8 +1,11 @@ """ Test cases for the ArcLengthParamFinder. """ + import unittest + import numpy as np + from pyocc.edge import Edge from pyocc.geometry.arc_length_param_finder import ArcLengthParamFinder @@ -97,11 +100,11 @@ def test_uniform_arc_length_sampling(self): # Check that parameters are in ascending order for i in range(1, len(parameters)): - self.assertGreater(parameters[i], parameters[i-1]) + self.assertGreater(parameters[i], parameters[i - 1]) # Check that spacing in arc length is uniform arc_lengths = [finder.parameter_to_arc_length(p) for p in parameters] - spacings = [arc_lengths[i+1] - arc_lengths[i] for i in range(len(arc_lengths)-1)] + spacings = [arc_lengths[i + 1] - arc_lengths[i] for i in range(len(arc_lengths) - 1)] # All spacings should be approximately equal expected_spacing = spacings[0] @@ -189,7 +192,7 @@ def test_parameterization_monotonicity(self): arc_lengths = [finder.parameter_to_arc_length(p) for p in parameters] for i in range(1, len(arc_lengths)): - self.assertGreaterEqual(arc_lengths[i], arc_lengths[i-1]) + self.assertGreaterEqual(arc_lengths[i], arc_lengths[i - 1]) def test_performance_with_many_samples(self): """Test performance with many sample points.""" @@ -245,5 +248,5 @@ def test_complex_curve_parameterization(self): self.skipTest(f"Arc edge not available: {e}") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/base_test.py b/tests/src/pyocc/base_test.py index cf9a6f3..9bc1d3f 100644 --- a/tests/src/pyocc/base_test.py +++ b/tests/src/pyocc/base_test.py @@ -1,27 +1,14 @@ """ Tests for the base.py module containing core mixin classes. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Shape from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox # Import mixin classes for testing -from pyocc.base import ( - TopologyUtils, - VertexContainerMixin, - EdgeContainerMixin, - FaceContainerMixin, - WireContainerMixin, - ShellContainerMixin, - SolidContainerMixin, - BottomUpFaceIterator, - BottomUpEdgeIterator, - SurfacePropertiesMixin, - VolumePropertiesMixin, - BoundingBoxMixin, - TriangulatorMixin -) +from pyocc.base import TopologyUtils # We need something that uses these mixins for testing # Import Solid which uses several mixins @@ -41,6 +28,7 @@ def test_topology_utils(self): """Test the TopologyUtils class.""" # Test get_shapes_of_type from OCC.Core.TopAbs import TopAbs_FACE + faces = TopologyUtils.get_shapes_of_type(self.box.topods_shape(), TopAbs_FACE) self.assertEqual(len(faces), 6) # A box has 6 faces @@ -107,7 +95,9 @@ def test_face_container_mixin(self): def test_surface_properties_mixin(self): """Test the SurfacePropertiesMixin.""" # Calculate the expected surface area of a 10x20x30 box - expected_area = 2 * (10*20 + 10*30 + 20*30) # 2*(front/back + top/bottom + left/right) + expected_area = 2 * ( + 10 * 20 + 10 * 30 + 20 * 30 + ) # 2*(front/back + top/bottom + left/right) # Test surface area calculation area = self.box.area() @@ -166,5 +156,5 @@ def test_triangulator_mixin(self): # The same placeholder caveat applies here -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/compound_test.py b/tests/src/pyocc/compound_test.py index c1b8b89..1c6b04c 100644 --- a/tests/src/pyocc/compound_test.py +++ b/tests/src/pyocc/compound_test.py @@ -1,21 +1,16 @@ """ Tests for the compound.py module containing Compound implementation. """ -import unittest + import os import tempfile -import numpy as np -from OCC.Core.TopoDS import TopoDS_Compound -# Use just the import without the specific class since it appears to be missing in this version -from OCC.Core import BRepBuilderAPI +import unittest from pyocc.compound import Compound -from pyocc.solid import Solid -from pyocc.shell import Shell -from pyocc.face import Face from pyocc.edge import Edge -from pyocc.vertex import Vertex +from pyocc.face import Face from pyocc.geometry.box import Box +from pyocc.solid import Solid class CompoundTest(unittest.TestCase): @@ -202,5 +197,5 @@ def test_string_representation(self): self.assertTrue("3 edges" in edge_compound_str) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/display_data_test.py b/tests/src/pyocc/display_data_test.py index d8b6aa4..6b62194 100644 --- a/tests/src/pyocc/display_data_test.py +++ b/tests/src/pyocc/display_data_test.py @@ -1,17 +1,20 @@ """ Test cases for the display data extraction system. """ + import unittest + import numpy as np -from pyocc.solid import Solid + from pyocc.display_data import ( - get_shape_display_data, - get_face_display_data, - get_edge_display_data, + compute_vertex_normals, extract_triangulation_data, extract_wireframe_data, - compute_vertex_normals + get_edge_display_data, + get_face_display_data, + get_shape_display_data, ) +from pyocc.solid import Solid class TestDisplayData(unittest.TestCase): @@ -28,11 +31,11 @@ def test_shape_display_data_extraction(self): display_data = get_shape_display_data(self.box) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] self.assertIsInstance(vertices, np.ndarray) self.assertIsInstance(triangles, np.ndarray) @@ -43,40 +46,33 @@ def test_shape_display_data_extraction(self): def test_shape_display_data_with_colors(self): """Test shape display data extraction with color information.""" - display_data = get_shape_display_data( - self.sphere, - color=(0.8, 0.2, 0.4), - transparency=0.3 - ) + display_data = get_shape_display_data(self.sphere, color=(0.8, 0.2, 0.4), transparency=0.3) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) # Color information should be included - if 'colors' in display_data: - colors = display_data['colors'] + if "colors" in display_data: + colors = display_data["colors"] self.assertIsInstance(colors, np.ndarray) self.assertEqual(colors.shape[1], 3) # RGB colors - if 'transparency' in display_data: - transparency = display_data['transparency'] + if "transparency" in display_data: + transparency = display_data["transparency"] self.assertIsInstance(transparency, (float, np.ndarray)) def test_shape_display_data_with_normals(self): """Test shape display data extraction with normal vectors.""" - display_data = get_shape_display_data( - self.cylinder, - include_normals=True - ) + display_data = get_shape_display_data(self.cylinder, include_normals=True) self.assertIsInstance(display_data, dict) - if 'normals' in display_data: - normals = display_data['normals'] + if "normals" in display_data: + normals = display_data["normals"] self.assertIsInstance(normals, np.ndarray) self.assertEqual(normals.shape[1], 3) # 3D normal vectors - self.assertEqual(normals.shape[0], display_data['vertices'].shape[0]) + self.assertEqual(normals.shape[0], display_data["vertices"].shape[0]) def test_face_display_data_extraction(self): """Test face-specific display data extraction.""" @@ -86,11 +82,11 @@ def test_face_display_data_extraction(self): display_data = get_face_display_data(face) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] self.assertIsInstance(vertices, np.ndarray) self.assertIsInstance(triangles, np.ndarray) @@ -105,9 +101,9 @@ def test_edge_display_data_extraction(self): display_data = get_edge_display_data(edge) self.assertIsInstance(display_data, dict) - self.assertIn('points', display_data) + self.assertIn("points", display_data) - points = display_data['points'] + points = display_data["points"] self.assertIsInstance(points, np.ndarray) self.assertEqual(points.shape[1], 3) # 3D points self.assertGreater(points.shape[0], 1) # At least 2 points @@ -117,11 +113,11 @@ def test_triangulation_data_extraction(self): triangulation_data = extract_triangulation_data(self.sphere) self.assertIsInstance(triangulation_data, dict) - self.assertIn('vertices', triangulation_data) - self.assertIn('triangles', triangulation_data) + self.assertIn("vertices", triangulation_data) + self.assertIn("triangles", triangulation_data) - vertices = triangulation_data['vertices'] - triangles = triangulation_data['triangles'] + vertices = triangulation_data["vertices"] + triangles = triangulation_data["triangles"] self.assertIsInstance(vertices, np.ndarray) self.assertIsInstance(triangles, np.ndarray) @@ -133,11 +129,11 @@ def test_wireframe_data_extraction(self): wireframe_data = extract_wireframe_data(self.box) self.assertIsInstance(wireframe_data, dict) - self.assertIn('points', wireframe_data) - self.assertIn('lines', wireframe_data) + self.assertIn("points", wireframe_data) + self.assertIn("lines", wireframe_data) - points = wireframe_data['points'] - lines = wireframe_data['lines'] + points = wireframe_data["points"] + lines = wireframe_data["lines"] self.assertIsInstance(points, np.ndarray) self.assertIsInstance(lines, np.ndarray) @@ -147,8 +143,8 @@ def test_wireframe_data_extraction(self): def test_vertex_normals_computation(self): """Test vertex normal computation.""" display_data = get_shape_display_data(self.sphere) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] normals = compute_vertex_normals(vertices, triangles) @@ -167,8 +163,8 @@ def test_display_data_consistency(self): for shape in shapes: display_data = get_shape_display_data(shape) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] # Check that triangle indices are valid max_vertex_index = vertices.shape[0] - 1 @@ -181,22 +177,14 @@ def test_display_data_consistency(self): def test_display_data_with_different_resolutions(self): """Test display data extraction with different tessellation resolutions.""" # Test with coarse tessellation - coarse_data = get_shape_display_data( - self.sphere, - deflection=0.1, - angular_deflection=0.5 - ) + coarse_data = get_shape_display_data(self.sphere, deflection=0.1, angular_deflection=0.5) # Test with fine tessellation - fine_data = get_shape_display_data( - self.sphere, - deflection=0.01, - angular_deflection=0.1 - ) + fine_data = get_shape_display_data(self.sphere, deflection=0.01, angular_deflection=0.1) # Fine tessellation should have more triangles - coarse_triangles = coarse_data['triangles'].shape[0] - fine_triangles = fine_data['triangles'].shape[0] + coarse_triangles = coarse_data["triangles"].shape[0] + fine_triangles = fine_data["triangles"].shape[0] self.assertGreater(fine_triangles, coarse_triangles) @@ -209,8 +197,8 @@ def test_display_data_caching(self): data2 = get_shape_display_data(self.box) # Data should be identical - np.testing.assert_array_equal(data1['vertices'], data2['vertices']) - np.testing.assert_array_equal(data1['triangles'], data2['triangles']) + np.testing.assert_array_equal(data1["vertices"], data2["vertices"]) + np.testing.assert_array_equal(data1["triangles"], data2["triangles"]) def test_display_data_error_handling(self): """Test error handling in display data extraction.""" @@ -231,19 +219,20 @@ def test_large_shape_display_data(self): """Test display data extraction for larger/more complex shapes.""" # Create a more complex shape try: - complex_shape = self.box.union( - self.sphere.translate(np.array([1.0, 1.0, 1.0])) - ) + # Place the sphere at a box corner so part of it protrudes; a sphere at + # the box centre would be fully enclosed and the union would just be the + # box (no curved surface, too few triangles to be a "complex" shape). + complex_shape = self.box.union(self.sphere.translate(np.array([2.0, 2.0, 2.0]))) display_data = get_shape_display_data(complex_shape) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) # Should have more data than simple shapes - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] self.assertGreater(vertices.shape[0], 20) # Should have many vertices self.assertGreater(triangles.shape[0], 20) # Should have many triangles @@ -257,8 +246,8 @@ def test_display_data_memory_usage(self): # This is a basic test to ensure we're not creating excessive data display_data = get_shape_display_data(self.sphere) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] # Check reasonable limits (adjust based on actual requirements) self.assertLess(vertices.shape[0], 10000) # Not too many vertices @@ -268,23 +257,20 @@ def test_display_data_formats(self): """Test different output formats for display data.""" # Test default format display_data = get_shape_display_data(self.box) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) # Test with additional data display_data = get_shape_display_data( - self.box, - include_normals=True, - include_colors=True, - include_texture_coords=False + self.box, include_normals=True, include_colors=True, include_texture_coords=False ) - if 'normals' in display_data: - self.assertIsInstance(display_data['normals'], np.ndarray) + if "normals" in display_data: + self.assertIsInstance(display_data["normals"], np.ndarray) - if 'colors' in display_data: - self.assertIsInstance(display_data['colors'], np.ndarray) + if "colors" in display_data: + self.assertIsInstance(display_data["colors"], np.ndarray) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/edge_data_extractor_test.py b/tests/src/pyocc/edge_data_extractor_test.py index 8fcb9bf..44dc4a9 100644 --- a/tests/src/pyocc/edge_data_extractor_test.py +++ b/tests/src/pyocc/edge_data_extractor_test.py @@ -4,17 +4,13 @@ This module provides comprehensive tests for the EdgeDataExtractor class, including convexity analysis, geometric data sampling, and edge analysis. """ -import pytest -import numpy as np -import sys -import os -# Add src to path for imports -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) +import numpy as np +import pytest -from pyocc.solid import Solid from pyocc.edge import Edge -from pyocc.edge_data_extractor import EdgeDataExtractor, EdgeConvexity +from pyocc.edge_data_extractor import EdgeConvexity, EdgeDataExtractor +from pyocc.solid import Solid class TestEdgeDataExtractor: @@ -38,7 +34,7 @@ def test_constructor_with_solid(self): assert extractor.edge == self.test_edge assert extractor.solid == self.box assert extractor._adjacent_faces is None # Should be lazy loaded - assert extractor._curve_adaptor is None # Should be lazy loaded + assert extractor._curve_adaptor is None # Should be lazy loaded def test_constructor_without_solid(self): """Test EdgeDataExtractor constructor without solid context.""" @@ -75,8 +71,12 @@ def test_classify_convexity_with_solid(self): # Should return a valid EdgeConvexity enum value assert isinstance(convexity, EdgeConvexity) - assert convexity in [EdgeConvexity.CONVEX, EdgeConvexity.CONCAVE, - EdgeConvexity.SMOOTH, EdgeConvexity.UNDEFINED] + assert convexity in [ + EdgeConvexity.CONVEX, + EdgeConvexity.CONCAVE, + EdgeConvexity.SMOOTH, + EdgeConvexity.UNDEFINED, + ] def test_classify_convexity_without_solid(self): """Test convexity classification without solid context.""" @@ -92,22 +92,22 @@ def test_sample_geometric_data_basic(self): data = extractor.sample_geometric_data(num_samples=5) # Check required keys - assert 'points' in data - assert 'tangents' in data - assert 'parameters' in data - assert 'convexity' in data + assert "points" in data + assert "tangents" in data + assert "parameters" in data + assert "convexity" in data # Check data shapes - assert data['points'].shape == (5, 3) - assert data['tangents'].shape == (5, 3) - assert data['parameters'].shape == (5,) + assert data["points"].shape == (5, 3) + assert data["tangents"].shape == (5, 3) + assert data["parameters"].shape == (5,) # Check parameter range - assert np.allclose(data['parameters'], np.linspace(0, 1, 5)) + assert np.allclose(data["parameters"], np.linspace(0, 1, 5)) # Check that points and tangents are valid - assert np.all(np.isfinite(data['points'])) - assert np.all(np.isfinite(data['tangents'])) + assert np.all(np.isfinite(data["points"])) + assert np.all(np.isfinite(data["tangents"])) def test_sample_geometric_data_different_sample_counts(self): """Test geometric data sampling with different sample counts.""" @@ -116,9 +116,9 @@ def test_sample_geometric_data_different_sample_counts(self): for num_samples in [1, 3, 10, 20]: data = extractor.sample_geometric_data(num_samples=num_samples) - assert data['points'].shape == (num_samples, 3) - assert data['tangents'].shape == (num_samples, 3) - assert data['parameters'].shape == (num_samples,) + assert data["points"].shape == (num_samples, 3) + assert data["tangents"].shape == (num_samples, 3) + assert data["parameters"].shape == (num_samples,) def test_sample_geometric_data_line_properties(self): """Test geometric data sampling for a line edge with known properties.""" @@ -130,11 +130,11 @@ def test_sample_geometric_data_line_properties(self): # Check that points are along the line expected_points = np.array([[0, 0, 0], [0.5, 0.5, 0.5], [1, 1, 1]]) - assert np.allclose(data['points'], expected_points, atol=1e-10) + assert np.allclose(data["points"], expected_points, atol=1e-10) # Check that tangents are normalized and point in the right direction expected_tangent = np.array([1, 1, 1]) / np.sqrt(3) - for tangent in data['tangents']: + for tangent in data["tangents"]: assert np.allclose(tangent, expected_tangent, atol=1e-10) def test_curvature_analysis(self): @@ -143,19 +143,19 @@ def test_curvature_analysis(self): curvature_data = extractor.get_curvature_analysis(num_samples=5) # Check required keys - assert 'parameters' in curvature_data - assert 'curvatures' in curvature_data - assert 'max_curvature' in curvature_data - assert 'min_curvature' in curvature_data + assert "parameters" in curvature_data + assert "curvatures" in curvature_data + assert "max_curvature" in curvature_data + assert "min_curvature" in curvature_data # Check data shapes - assert curvature_data['parameters'].shape == (5,) - assert curvature_data['curvatures'].shape == (5,) + assert curvature_data["parameters"].shape == (5,) + assert curvature_data["curvatures"].shape == (5,) # Check that curvatures are valid numbers - assert np.all(np.isfinite(curvature_data['curvatures'])) - assert isinstance(curvature_data['max_curvature'], (int, float)) - assert isinstance(curvature_data['min_curvature'], (int, float)) + assert np.all(np.isfinite(curvature_data["curvatures"])) + assert isinstance(curvature_data["max_curvature"], (int, float)) + assert isinstance(curvature_data["min_curvature"], (int, float)) def test_curvature_analysis_line(self): """Test curvature analysis for a straight line (should be zero).""" @@ -163,9 +163,9 @@ def test_curvature_analysis_line(self): curvature_data = extractor.get_curvature_analysis(num_samples=3) # Line should have zero curvature everywhere - assert np.allclose(curvature_data['curvatures'], 0.0, atol=1e-10) - assert abs(curvature_data['max_curvature']) < 1e-10 - assert abs(curvature_data['min_curvature']) < 1e-10 + assert np.allclose(curvature_data["curvatures"], 0.0, atol=1e-10) + assert abs(curvature_data["max_curvature"]) < 1e-10 + assert abs(curvature_data["min_curvature"]) < 1e-10 def test_is_sharp_edge(self): """Test sharp edge detection.""" @@ -215,8 +215,8 @@ def test_error_handling_invalid_projection(self): # This should not raise an exception even if projection fails data = extractor.sample_geometric_data(num_samples=3) - assert 'points' in data - assert 'tangents' in data + assert "points" in data + assert "tangents" in data def test_edge_convexity_enum_values(self): """Test that EdgeConvexity enum has expected values.""" @@ -234,9 +234,9 @@ def test_geometric_data_consistency(self): data2 = extractor.sample_geometric_data(num_samples=5) # Results should be identical - assert np.allclose(data1['points'], data2['points']) - assert np.allclose(data1['tangents'], data2['tangents']) - assert np.allclose(data1['parameters'], data2['parameters']) + assert np.allclose(data1["points"], data2["points"]) + assert np.allclose(data1["tangents"], data2["tangents"]) + assert np.allclose(data1["parameters"], data2["parameters"]) def test_empty_face_normals_fallback(self): """Test that face normals fall back gracefully when computation fails.""" @@ -244,8 +244,8 @@ def test_empty_face_normals_fallback(self): data = extractor.sample_geometric_data(num_samples=3) # Without adjacent faces, there should be no face normals - assert 'normals_face1' not in data - assert 'normals_face2' not in data + assert "normals_face1" not in data + assert "normals_face2" not in data @pytest.mark.parametrize("num_samples", [1, 2, 5, 10]) def test_sample_count_parametrized(self, num_samples): @@ -253,14 +253,18 @@ def test_sample_count_parametrized(self, num_samples): extractor = EdgeDataExtractor(self.standalone_edge) data = extractor.sample_geometric_data(num_samples=num_samples) - assert data['points'].shape[0] == num_samples - assert data['tangents'].shape[0] == num_samples - assert data['parameters'].shape[0] == num_samples + assert data["points"].shape[0] == num_samples + assert data["tangents"].shape[0] == num_samples + assert data["parameters"].shape[0] == num_samples class TestEdgeDataExtractorIntegration: """Integration tests for EdgeDataExtractor with different edge types.""" + def setup_method(self): + """Create a standalone edge fixture shared by the integration tests.""" + self.standalone_edge = Edge.make_line_from_points([0, 0, 0], [1, 1, 1]) + def test_with_circle_edge(self): """Test EdgeDataExtractor with a circular edge.""" # Create a cylinder to get circular edges @@ -279,10 +283,10 @@ def test_with_circle_edge(self): data = extractor.sample_geometric_data(num_samples=8) # Check that we get valid data - assert data['points'].shape == (8, 3) - assert data['tangents'].shape == (8, 3) - assert np.all(np.isfinite(data['points'])) - assert np.all(np.isfinite(data['tangents'])) + assert data["points"].shape == (8, 3) + assert data["tangents"].shape == (8, 3) + assert np.all(np.isfinite(data["points"])) + assert np.all(np.isfinite(data["tangents"])) def test_with_sphere_edges(self): """Test EdgeDataExtractor with sphere edges.""" @@ -295,8 +299,8 @@ def test_with_sphere_edges(self): # Should not crash even with complex geometry data = extractor.sample_geometric_data(num_samples=5) - assert 'points' in data - assert 'tangents' in data + assert "points" in data + assert "tangents" in data def test_performance_with_many_samples(self): """Test performance with a large number of samples.""" @@ -304,8 +308,8 @@ def test_performance_with_many_samples(self): # This should complete in reasonable time data = extractor.sample_geometric_data(num_samples=100) - assert data['points'].shape == (100, 3) - assert data['tangents'].shape == (100, 3) + assert data["points"].shape == (100, 3) + assert data["tangents"].shape == (100, 3) # Fixtures for shared test data @@ -330,10 +334,10 @@ def test_with_simple_fixtures(simple_box, simple_edge): data1 = extractor_with_solid.sample_geometric_data(num_samples=3) data2 = extractor_standalone.sample_geometric_data(num_samples=3) - assert data1['points'].shape == (3, 3) - assert data2['points'].shape == (3, 3) + assert data1["points"].shape == (3, 3) + assert data2["points"].shape == (3, 3) if __name__ == "__main__": # Run tests directly if executed as a script - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"]) diff --git a/tests/src/pyocc/edge_test.py b/tests/src/pyocc/edge_test.py index cb745fa..58a641a 100644 --- a/tests/src/pyocc/edge_test.py +++ b/tests/src/pyocc/edge_test.py @@ -1,17 +1,18 @@ """ Tests for the edge.py module containing Edge implementation. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Edge from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge -from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax1, gp_Lin, gp_Circ, gp -from OCC.Core.GeomAbs import GeomAbs_Line, GeomAbs_Circle -from OCC.Core.GC import GC_MakeSegment, GC_MakeCircle +from OCC.Core.GC import GC_MakeSegment +from OCC.Core.GeomAbs import GeomAbs_Circle, GeomAbs_Line +from OCC.Core.gp import gp, gp_Circ, gp_Pnt from pyocc.edge import Edge -from pyocc.solid import Solid from pyocc.geometry.interval import Interval +from pyocc.solid import Solid class EdgeTest(unittest.TestCase): @@ -54,7 +55,7 @@ def test_curve_properties(self): # Circle bounds should span full circle bounds = self.circle_edge.u_bounds() self.assertIsInstance(bounds, Interval) - self.assertGreaterEqual(bounds.length(), 2*np.pi) # Length should be >= 2π + self.assertGreaterEqual(bounds.length(), 2 * np.pi) # Length should be >= 2π def test_length(self): """Test length calculation.""" @@ -64,7 +65,7 @@ def test_length(self): # Circle length circle_length = self.circle_edge.length() - self.assertAlmostEqual(circle_length, 2*np.pi*5.0, places=5) # 2πr + self.assertAlmostEqual(circle_length, 2 * np.pi * 5.0, places=5) # 2πr def test_point_and_tangent(self): """Test point and tangent calculation.""" @@ -84,6 +85,18 @@ def test_point_and_tangent(self): d0 = self.line_edge.first_derivative(0.0) self.assertTrue(np.linalg.norm(d0) > 0) # Non-zero derivative + def test_point_raw_parameter(self): + """Regression: point() with a raw curve parameter (outside the normalized 0..1 range). + + The raw-parameter branch previously read Interval.start/.end, which do not + exist (Interval exposes .a/.b), raising an AttributeError that callers such + as ugrid silently swallowed and replaced with default points. line_edge + spans (0,0,0)->(10,0,0) with u_bounds == [0, 10], so raw parameters 5 and 10 + must evaluate to the real points on the curve. + """ + np.testing.assert_array_almost_equal(self.line_edge.point(5.0), [5, 0, 0], decimal=5) + np.testing.assert_array_almost_equal(self.line_edge.point(10.0), [10, 0, 0], decimal=5) + def test_closed_and_periodic(self): """Test closed and periodic properties.""" # Line segment is not closed or periodic @@ -123,7 +136,7 @@ def test_polyline(self): # Check all points are on circle (radius = 5) for pt in points: - radius = np.sqrt(pt[0]**2 + pt[1]**2) # XY plane circle + radius = np.sqrt(pt[0] ** 2 + pt[1] ** 2) # XY plane circle self.assertAlmostEqual(radius, 5.0, places=4) def test_closest_point(self): @@ -157,5 +170,5 @@ def test_string_representation(self): self.assertTrue("closed" in str(self.circle_edge)) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/entity_mapper_test.py b/tests/src/pyocc/entity_mapper_test.py index 288ac5a..bea6e20 100644 --- a/tests/src/pyocc/entity_mapper_test.py +++ b/tests/src/pyocc/entity_mapper_test.py @@ -1,10 +1,13 @@ """ Test cases for the EntityMapper system. """ + import unittest + import numpy as np -from pyocc.solid import Solid + from pyocc.entity_mapper import EntityMapper +from pyocc.solid import Solid class TestEntityMapper(unittest.TestCase): @@ -248,5 +251,5 @@ def test_serialization(self): self.assertEqual(color, "red") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/face_test.py b/tests/src/pyocc/face_test.py index 0cea3df..8fbb763 100644 --- a/tests/src/pyocc/face_test.py +++ b/tests/src/pyocc/face_test.py @@ -1,19 +1,18 @@ """ Tests for the face.py module containing Face implementation. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Face from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace -from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax3, gp_Pln, gp_Circ, gp_XOY -from OCC.Core.GeomAbs import GeomAbs_Plane from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder +from OCC.Core.GeomAbs import GeomAbs_Plane +from OCC.Core.gp import gp_Ax3, gp_Dir, gp_Pln, gp_Pnt from pyocc.face import Face -from pyocc.wire import Wire -from pyocc.solid import Solid from pyocc.geometry.box import Box -from pyocc.geometry.interval import Interval +from pyocc.solid import Solid class FaceTest(unittest.TestCase): @@ -100,7 +99,9 @@ def test_curvature(self): self.assertAlmostEqual(curvature, 0.0, places=5) # Gaussian curvature is 0 for cylinder mean_curvature = self.cylinder_face.mean_curvature(0, 5) - self.assertAlmostEqual(abs(mean_curvature), 1/(2*5.0), places=5) # (1/radius + 0)/2 = 1/(2*radius) + self.assertAlmostEqual( + abs(mean_curvature), 1 / (2 * 5.0), places=5 + ) # (1/radius + 0)/2 = 1/(2*radius) def test_project_point(self): """Test point projection.""" @@ -121,7 +122,7 @@ def test_area(self): # Box faces total area = 2(LW + LH + WH) box_faces_area = sum(face.area() for face in self.box.faces()) - expected_area = 2 * (10*20 + 10*30 + 20*30) + expected_area = 2 * (10 * 20 + 10 * 30 + 20 * 30) self.assertAlmostEqual(box_faces_area, expected_area, places=1) def test_container_mixins(self): @@ -151,5 +152,5 @@ def test_string_representation(self): self.assertTrue("area=" in str(self.plane_face)) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/geom_utils_test.py b/tests/src/pyocc/geom_utils_test.py index a0e33c5..26c141b 100644 --- a/tests/src/pyocc/geom_utils_test.py +++ b/tests/src/pyocc/geom_utils_test.py @@ -1,26 +1,29 @@ """ Test cases for geometry utilities. """ + import unittest + import numpy as np + from pyocc.geometry.geom_utils import ( - normalize_vector, + barycentric_coordinates, + compute_triangle_area, + compute_triangle_normal, + create_rotation_matrix, + create_scale_matrix, + create_translation_matrix, cross_product, dot_product, - vector_angle, + normalize_vector, point_distance, + point_in_triangle, point_line_distance, point_plane_distance, project_point_to_line, project_point_to_plane, - create_rotation_matrix, - create_translation_matrix, - create_scale_matrix, transform_points, - compute_triangle_normal, - compute_triangle_area, - point_in_triangle, - barycentric_coordinates + vector_angle, ) @@ -60,7 +63,7 @@ def test_dot_product(self): v1 = np.array([1.0, 2.0, 3.0]) v2 = np.array([4.0, 5.0, 6.0]) dot = dot_product(v1, v2) - expected = 1*4 + 2*5 + 3*6 # 32 + expected = 1 * 4 + 2 * 5 + 3 * 6 # 32 self.assertAlmostEqual(dot, expected) def test_vector_angle(self): @@ -164,11 +167,7 @@ def test_create_scale_matrix(self): def test_transform_points(self): """Test point transformation with matrices.""" - points = np.array([ - [1.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [0.0, 0.0, 1.0] - ]) + points = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) # Test translation translation = np.array([1.0, 2.0, 3.0]) @@ -232,7 +231,7 @@ def test_barycentric_coordinates(self): centroid = (v1 + v2 + v3) / 3.0 bary_coords = barycentric_coordinates(centroid, v1, v2, v3) - expected = np.array([1.0/3.0, 1.0/3.0, 1.0/3.0]) + expected = np.array([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]) np.testing.assert_array_almost_equal(bary_coords, expected, decimal=10) # Test that coordinates sum to 1 @@ -286,14 +285,13 @@ def test_combined_transformations(self): points = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) # Combine rotation and translation - rotation = create_rotation_matrix(np.array([0.0, 0.0, 1.0]), np.pi/2) + rotation = create_rotation_matrix(np.array([0.0, 0.0, 1.0]), np.pi / 2) translation = create_translation_matrix(np.array([1.0, 2.0, 3.0])) # Create homogeneous transformation matrix - combined = translation @ np.vstack([ - np.hstack([rotation, np.zeros((3, 1))]), - np.array([0, 0, 0, 1]) - ]) + combined = translation @ np.vstack( + [np.hstack([rotation, np.zeros((3, 1))]), np.array([0, 0, 0, 1])] + ) transformed = transform_points(points, combined) @@ -302,5 +300,5 @@ def test_combined_transformations(self): np.testing.assert_array_almost_equal(transformed[0], expected_first) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/geometry/__init__.py b/tests/src/pyocc/geometry/__init__.py index 7f56f28..3fd68e7 100644 --- a/tests/src/pyocc/geometry/__init__.py +++ b/tests/src/pyocc/geometry/__init__.py @@ -2,4 +2,4 @@ Geometry test modules for pyocc. This package contains unit tests for pyocc geometry functionality. -""" \ No newline at end of file +""" diff --git a/tests/src/pyocc/geometry/box_test.py b/tests/src/pyocc/geometry/box_test.py index dc1b5dc..b3b7e8d 100644 --- a/tests/src/pyocc/geometry/box_test.py +++ b/tests/src/pyocc/geometry/box_test.py @@ -1,8 +1,11 @@ """ Test suite for the Box class. """ + import unittest + import numpy as np + from pyocc.geometry.box import Box from pyocc.geometry.interval import Interval @@ -37,7 +40,7 @@ def test_from_points(self): [1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0], - [0.5, 1.5, 2.5] + [0.5, 1.5, 2.5], ] box = Box.from_points(points) @@ -75,7 +78,7 @@ def test_properties(self): self.assertEqual(box.volume(), 3.0 * 4.0 * 5.0) # Test surface area - self.assertEqual(box.surface_area(), 2.0 * (3.0*4.0 + 3.0*5.0 + 4.0*5.0)) + self.assertEqual(box.surface_area(), 2.0 * (3.0 * 4.0 + 3.0 * 5.0 + 4.0 * 5.0)) # Test intervals self.assertEqual(box.x_interval().a, 1.0) @@ -90,16 +93,18 @@ def test_corners(self): box = Box(np.array([1.0, 2.0, 3.0]), np.array([4.0, 5.0, 6.0])) corners = box.corners() - expected_corners = np.array([ - [1.0, 2.0, 3.0], # min corner - [4.0, 2.0, 3.0], - [4.0, 5.0, 3.0], - [1.0, 5.0, 3.0], - [1.0, 2.0, 6.0], - [4.0, 2.0, 6.0], - [4.0, 5.0, 6.0], # max corner - [1.0, 5.0, 6.0] - ]) + expected_corners = np.array( + [ + [1.0, 2.0, 3.0], # min corner + [4.0, 2.0, 3.0], + [4.0, 5.0, 3.0], + [1.0, 5.0, 3.0], + [1.0, 2.0, 6.0], + [4.0, 2.0, 6.0], + [4.0, 5.0, 6.0], # max corner + [1.0, 5.0, 6.0], + ] + ) np.testing.assert_array_equal(corners, expected_corners) @@ -156,16 +161,24 @@ def test_contains_box(self): self.assertTrue(box.contains_box(box)) # box contains itself # Test boxes that stick out - self.assertFalse(box.contains_box(Box(np.array([0.5, 3.0, 4.0]), np.array([3.0, 4.0, 5.0])))) - self.assertFalse(box.contains_box(Box(np.array([2.0, 3.0, 4.0]), np.array([4.5, 4.0, 5.0])))) + self.assertFalse( + box.contains_box(Box(np.array([0.5, 3.0, 4.0]), np.array([3.0, 4.0, 5.0]))) + ) + self.assertFalse( + box.contains_box(Box(np.array([2.0, 3.0, 4.0]), np.array([4.5, 4.0, 5.0]))) + ) def test_intersects(self): """Test box intersection detection.""" box = Box(np.array([1.0, 2.0, 3.0]), np.array([4.0, 5.0, 6.0])) # Test boxes that intersect - self.assertTrue(box.intersects(Box(np.array([0.0, 1.0, 2.0]), np.array([2.0, 3.0, 4.0])))) # overlap - self.assertTrue(box.intersects(Box(np.array([2.0, 3.0, 4.0]), np.array([3.0, 4.0, 5.0])))) # contained + self.assertTrue( + box.intersects(Box(np.array([0.0, 1.0, 2.0]), np.array([2.0, 3.0, 4.0]))) + ) # overlap + self.assertTrue( + box.intersects(Box(np.array([2.0, 3.0, 4.0]), np.array([3.0, 4.0, 5.0]))) + ) # contained self.assertTrue(box.intersects(box)) # self-intersection # Test boxes that don't intersect @@ -216,7 +229,9 @@ def test_distance_to_point(self): # Point outside box self.assertEqual(box.distance_to_point([0.0, 2.0, 3.0]), 1.0) # 1 unit away in x - self.assertEqual(box.distance_to_point([0.0, 0.0, 0.0]), np.sqrt(1**2 + 2**2 + 3**2)) # diagonal distance + self.assertEqual( + box.distance_to_point([0.0, 0.0, 0.0]), np.sqrt(1**2 + 2**2 + 3**2) + ) # diagonal distance def test_string_representation(self): """Test string representation of box.""" @@ -243,5 +258,5 @@ def test_equality(self): self.assertEqual(invalid1, invalid2) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/geometry/interval_test.py b/tests/src/pyocc/geometry/interval_test.py index 63e3a91..794ffd9 100644 --- a/tests/src/pyocc/geometry/interval_test.py +++ b/tests/src/pyocc/geometry/interval_test.py @@ -1,9 +1,9 @@ - """ Test suite for the Interval class. """ + import unittest -import math + from pyocc.geometry.interval import Interval @@ -108,5 +108,5 @@ def test_equals(self): self.assertNotEqual(interval1, "not_an_interval") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/graph_test.py b/tests/src/pyocc/graph_test.py index 9e36a91..0bfb4f7 100644 --- a/tests/src/pyocc/graph_test.py +++ b/tests/src/pyocc/graph_test.py @@ -1,12 +1,15 @@ """ Test cases for the Graph and topological analysis system. """ + import unittest + import numpy as np -from pyocc.solid import Solid -from pyocc.graph import FaceAdjacencyGraph, VertexAdjacencyGraph + from pyocc.edge import Edge from pyocc.face import Face +from pyocc.graph import FaceAdjacencyGraph, VertexAdjacencyGraph +from pyocc.solid import Solid from pyocc.vertex import Vertex @@ -23,7 +26,7 @@ def setUp(self): box2 = Solid.make_box(1.0, 1.0, 1.0).translate(np.array([0.5, 0.0, 0.0])) try: self.complex_shape = box1.union(box2) - except: + except Exception: self.complex_shape = box1 # Fallback if union fails def test_face_adjacency_graph_creation(self): @@ -314,9 +317,9 @@ def test_face_adjacency_graph_methods(self): # Test analyze_connectivity connectivity = graph.analyze_connectivity() - self.assertIn('connected_components', connectivity) - self.assertIn('num_components', connectivity) - self.assertIn('is_connected', connectivity) + self.assertIn("connected_components", connectivity) + self.assertIn("num_components", connectivity) + self.assertIn("is_connected", connectivity) # Test find_boundary_faces boundary_faces = graph.find_boundary_faces() @@ -332,13 +335,13 @@ def test_vertex_adjacency_graph_methods(self): # Test analyze_connectivity connectivity = graph.analyze_connectivity() - self.assertIn('connected_components', connectivity) + self.assertIn("connected_components", connectivity) # Test find_valence_anomalies anomalies = graph.find_valence_anomalies(expected_valence=3) - self.assertIn('high_valence', anomalies) - self.assertIn('low_valence', anomalies) - self.assertIn('valence_distribution', anomalies) + self.assertIn("high_valence", anomalies) + self.assertIn("low_valence", anomalies) + self.assertIn("valence_distribution", anomalies) def test_standalone_analysis_functions(self): """Test standalone analysis functions.""" @@ -349,10 +352,12 @@ def test_standalone_analysis_functions(self): # Test analyze_connectivity face_connectivity = pyocc.graph.analyze_connectivity(face_graph.get_nx_graph(), "face") - self.assertIn('is_connected', face_connectivity) + self.assertIn("is_connected", face_connectivity) - vertex_connectivity = pyocc.graph.analyze_connectivity(vertex_graph.get_nx_graph(), "vertex") - self.assertIn('is_connected', vertex_connectivity) + vertex_connectivity = pyocc.graph.analyze_connectivity( + vertex_graph.get_nx_graph(), "vertex" + ) + self.assertIn("is_connected", vertex_connectivity) # Test find_boundary_faces boundary_faces = pyocc.graph.find_boundary_faces(face_graph.get_nx_graph()) @@ -379,7 +384,9 @@ def test_convenience_functions(self): # Test get_shared_edge if neighbors: neighbor_id = neighbors[0] - shared_edge = pyocc.graph.get_shared_edge(face_graph.get_nx_graph(), face_id, neighbor_id) + shared_edge = pyocc.graph.get_shared_edge( + face_graph.get_nx_graph(), face_id, neighbor_id + ) # Edge might be None or an Edge object if shared_edge is not None: self.assertIsInstance(shared_edge, Edge) @@ -411,8 +418,7 @@ def test_empty_graph_handling(self): try: # Create a simple edge line_edge = Edge.make_line_from_points( - np.array([0.0, 0.0, 0.0]), - np.array([1.0, 0.0, 0.0]) + np.array([0.0, 0.0, 0.0]), np.array([1.0, 0.0, 0.0]) ) # Try to create graphs from edge (might have unusual topology) @@ -436,31 +442,31 @@ def test_graph_node_and_edge_data(self): nx_face_graph = face_graph.get_nx_graph() for node_id in nx_face_graph.nodes(): node_data = nx_face_graph.nodes[node_id] - self.assertIn('face', node_data) - face_obj = node_data['face'] + self.assertIn("face", node_data) + face_obj = node_data["face"] self.assertIsInstance(face_obj, Face) # Test face graph edge data for edge in nx_face_graph.edges(data=True): node1, node2, edge_data = edge - if 'edge' in edge_data: - self.assertIsInstance(edge_data['edge'], Edge) - if 'continuity' in edge_data: - self.assertIsInstance(edge_data['continuity'], str) + if "edge" in edge_data: + self.assertIsInstance(edge_data["edge"], Edge) + if "continuity" in edge_data: + self.assertIsInstance(edge_data["continuity"], str) # Test vertex graph node data nx_vertex_graph = vertex_graph.get_nx_graph() for node_id in nx_vertex_graph.nodes(): node_data = nx_vertex_graph.nodes[node_id] - self.assertIn('vertex', node_data) - vertex_obj = node_data['vertex'] + self.assertIn("vertex", node_data) + vertex_obj = node_data["vertex"] self.assertIsInstance(vertex_obj, Vertex) # Test vertex graph edge data for edge in nx_vertex_graph.edges(data=True): node1, node2, edge_data = edge - if 'edge' in edge_data: - self.assertIsInstance(edge_data['edge'], Edge) + if "edge" in edge_data: + self.assertIsInstance(edge_data["edge"], Edge) def test_graph_metrics_and_analysis(self): """Test various graph metrics and analysis functions.""" @@ -472,21 +478,27 @@ def test_graph_metrics_and_analysis(self): vertex_connectivity = vertex_graph.analyze_connectivity() # Check structure of connectivity results - required_keys = ['connected_components', 'num_components', 'largest_component_size', - 'is_connected', 'node_degrees', 'avg_degree'] + required_keys = [ + "connected_components", + "num_components", + "largest_component_size", + "is_connected", + "node_degrees", + "avg_degree", + ] for key in required_keys: self.assertIn(key, face_connectivity) self.assertIn(key, vertex_connectivity) # Test that box is connected - self.assertTrue(face_connectivity['is_connected']) - self.assertTrue(vertex_connectivity['is_connected']) + self.assertTrue(face_connectivity["is_connected"]) + self.assertTrue(vertex_connectivity["is_connected"]) # Test degree analysis - self.assertIsInstance(face_connectivity['node_degrees'], dict) - self.assertIsInstance(face_connectivity['avg_degree'], (int, float)) - self.assertGreater(face_connectivity['avg_degree'], 0) + self.assertIsInstance(face_connectivity["node_degrees"], dict) + self.assertIsInstance(face_connectivity["avg_degree"], (int, float)) + self.assertGreater(face_connectivity["avg_degree"], 0) def test_boundary_and_anomaly_detection(self): """Test boundary face and vertex anomaly detection.""" @@ -502,12 +514,12 @@ def test_boundary_and_anomaly_detection(self): # Test vertex valence anomalies anomalies = vertex_graph.find_valence_anomalies(expected_valence=3) self.assertIsInstance(anomalies, dict) - self.assertIn('high_valence', anomalies) - self.assertIn('low_valence', anomalies) - self.assertIn('valence_distribution', anomalies) + self.assertIn("high_valence", anomalies) + self.assertIn("low_valence", anomalies) + self.assertIn("valence_distribution", anomalies) # Test valence distribution - valence_dist = anomalies['valence_distribution'] + valence_dist = anomalies["valence_distribution"] self.assertIsInstance(valence_dist, dict) # All values in distribution should be positive for count in valence_dist.values(): @@ -516,7 +528,7 @@ def test_boundary_and_anomaly_detection(self): def test_graph_robustness(self): """Test graph robustness with edge cases.""" # Test with complex shape if available - if hasattr(self, 'complex_shape') and self.complex_shape is not None: + if hasattr(self, "complex_shape") and self.complex_shape is not None: try: face_graph = FaceAdjacencyGraph(self.complex_shape) vertex_graph = VertexAdjacencyGraph(self.complex_shape) @@ -529,12 +541,12 @@ def test_graph_robustness(self): face_conn = face_graph.analyze_connectivity() vertex_conn = vertex_graph.analyze_connectivity() - self.assertIsInstance(face_conn['is_connected'], bool) - self.assertIsInstance(vertex_conn['is_connected'], bool) + self.assertIsInstance(face_conn["is_connected"], bool) + self.assertIsInstance(vertex_conn["is_connected"], bool) except Exception as e: self.skipTest(f"Complex shape graph analysis failed: {e}") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/io_test.py b/tests/src/pyocc/io_test.py new file mode 100644 index 0000000..4c2c98a --- /dev/null +++ b/tests/src/pyocc/io_test.py @@ -0,0 +1,61 @@ +""" +Tests for the io module: STEP / STL / BREP save and load round-trips. + +These guard against regressions such as the loaders silently returning None +(load_stl/load_brep previously called a non-existent Shape.create_from_topods). +""" + +import os +import tempfile +import unittest + +from pyocc.io import load_brep, load_step, load_stl, save_brep, save_step, save_stl +from pyocc.solid import Solid + + +class IOTest(unittest.TestCase): + """Round-trip tests for the supported file formats.""" + + def setUp(self): + self.box = Solid.make_box(10.0, 20.0, 30.0) + self._tmpdir = tempfile.TemporaryDirectory() + self.tmp = self._tmpdir.name + + def tearDown(self): + self._tmpdir.cleanup() + + def _path(self, name): + return os.path.join(self.tmp, name) + + def test_step_round_trip(self): + path = self._path("box.step") + self.assertTrue(save_step(self.box, path)) + self.assertGreater(os.path.getsize(path), 0) + + loaded = load_step(path) + self.assertIsNotNone(loaded) + self.assertEqual(loaded.num_faces(), 6) + + def test_stl_round_trip(self): + path = self._path("box.stl") + self.assertTrue(save_stl(self.box, path, linear_deflection=0.1)) + self.assertGreater(os.path.getsize(path), 0) + + loaded = load_stl(path) + self.assertIsNotNone(loaded) + # STL is a triangle mesh, so the box's 6 faces become many triangles. + self.assertGreater(loaded.num_faces(), 0) + + def test_brep_round_trip(self): + path = self._path("box.brep") + self.assertTrue(save_brep(self.box, path)) + self.assertGreater(os.path.getsize(path), 0) + + loaded = load_brep(path) + self.assertIsNotNone(loaded) + # BREP is lossless, so the exact 6-face topology survives the round-trip. + self.assertEqual(loaded.num_faces(), 6) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/render_multiview_test.py b/tests/src/pyocc/render_multiview_test.py index 5fc15af..73263c9 100644 --- a/tests/src/pyocc/render_multiview_test.py +++ b/tests/src/pyocc/render_multiview_test.py @@ -1,20 +1,22 @@ """ Test cases for the batch rendering and multiview system. """ -import unittest -import tempfile + import os +import tempfile +import unittest + import numpy as np -from pathlib import Path -from pyocc.solid import Solid + from pyocc.batch.render_multiview import ( - ViewAngle, - RenderSettings, - MultiViewRenderer, BatchProcessor, + MultiViewRenderer, + RenderSettings, + ViewAngle, create_comparison_grid, - render_animation_frames + render_animation_frames, ) +from pyocc.solid import Solid class TestRenderMultiview(unittest.TestCase): @@ -32,6 +34,7 @@ def setUp(self): def tearDown(self): """Clean up temporary files.""" import shutil + if os.path.exists(self.temp_dir): shutil.rmtree(self.temp_dir) @@ -50,10 +53,7 @@ def test_render_settings_creation(self): # Test custom settings custom_settings = RenderSettings( - width=800, - height=600, - background_color=(1.0, 1.0, 1.0), - use_antialiasing=False + width=800, height=600, background_color=(1.0, 1.0, 1.0), use_antialiasing=False ) self.assertEqual(custom_settings.width, 800) self.assertEqual(custom_settings.height, 600) @@ -76,11 +76,7 @@ def test_standard_view_rendering(self): renderer = MultiViewRenderer(settings) # Test rendering different standard views - views_to_test = [ - ViewAngle.FRONT, - ViewAngle.TOP, - ViewAngle.ISOMETRIC - ] + views_to_test = [ViewAngle.FRONT, ViewAngle.TOP, ViewAngle.ISOMETRIC] for view_angle in views_to_test: output_path = os.path.join(self.temp_dir, f"test_{view_angle.value}.png") @@ -107,11 +103,7 @@ def test_custom_view_rendering(self): output_path = os.path.join(self.temp_dir, "custom_view.png") renderer.render_custom_view( - self.sphere, - camera_position, - target_position, - up_vector, - output_path + self.sphere, camera_position, target_position, up_vector, output_path ) self.assertTrue(os.path.exists(output_path)) @@ -158,19 +150,13 @@ def test_batch_shape_processing(self): shape_names = ["box", "sphere", "cylinder"] processor.process_shapes( - shapes, - shape_names, - self.temp_dir, - [ViewAngle.FRONT, ViewAngle.ISOMETRIC] + shapes, shape_names, self.temp_dir, [ViewAngle.FRONT, ViewAngle.ISOMETRIC] ) # Check that files were created for each shape and view for shape_name in shape_names: for view in [ViewAngle.FRONT, ViewAngle.ISOMETRIC]: - expected_file = os.path.join( - self.temp_dir, - f"{shape_name}_{view.value}.png" - ) + expected_file = os.path.join(self.temp_dir, f"{shape_name}_{view.value}.png") self.assertTrue(os.path.exists(expected_file)) except Exception as e: @@ -186,11 +172,7 @@ def test_comparison_grid_creation(self): output_path = os.path.join(self.temp_dir, "comparison_grid.png") create_comparison_grid( - shapes, - shape_names, - output_path, - grid_size=(2, 1), - image_size=(300, 300) + shapes, shape_names, output_path, grid_size=(2, 1), image_size=(300, 300) ) self.assertTrue(os.path.exists(output_path)) @@ -205,7 +187,7 @@ def test_animation_frame_rendering(self): try: # Create transformation sequence (rotation) num_frames = 8 - angles = np.linspace(0, 2*np.pi, num_frames, endpoint=False) + angles = np.linspace(0, 2 * np.pi, num_frames, endpoint=False) transformations = [] for angle in angles: @@ -220,12 +202,7 @@ def test_animation_frame_rendering(self): output_dir = os.path.join(self.temp_dir, "animation_frames") os.makedirs(output_dir, exist_ok=True) - render_animation_frames( - self.box, - transformations, - output_dir, - image_size=(200, 200) - ) + render_animation_frames(self.box, transformations, output_dir, image_size=(200, 200)) # Check that frame files were created for i in range(num_frames): @@ -260,19 +237,11 @@ def test_parallel_processing(self): shapes = [self.box, self.sphere, self.cylinder] shape_names = ["box", "sphere", "cylinder"] - processor.process_shapes_parallel( - shapes, - shape_names, - self.temp_dir, - [ViewAngle.FRONT] - ) + processor.process_shapes_parallel(shapes, shape_names, self.temp_dir, [ViewAngle.FRONT]) # Check that files were created for shape_name in shape_names: - expected_file = os.path.join( - self.temp_dir, - f"{shape_name}_front.png" - ) + expected_file = os.path.join(self.temp_dir, f"{shape_name}_front.png") self.assertTrue(os.path.exists(expected_file)) except Exception as e: @@ -297,7 +266,7 @@ def test_output_format_options(self): jpeg_path = os.path.join(self.temp_dir, "test.jpg") jpeg_renderer.render_view(self.box, ViewAngle.FRONT, jpeg_path) self.assertTrue(os.path.exists(jpeg_path)) - except: + except Exception: pass # JPEG might not be supported except Exception as e: @@ -309,10 +278,7 @@ def test_quality_settings(self): try: # High quality settings hq_settings = RenderSettings( - width=800, - height=600, - use_antialiasing=True, - use_shadows=True + width=800, height=600, use_antialiasing=True, use_shadows=True ) hq_renderer = MultiViewRenderer(hq_settings) @@ -321,10 +287,7 @@ def test_quality_settings(self): # Low quality settings lq_settings = RenderSettings( - width=400, - height=300, - use_antialiasing=False, - use_shadows=False + width=400, height=300, use_antialiasing=False, use_shadows=False ) lq_renderer = MultiViewRenderer(lq_settings) @@ -385,15 +348,10 @@ def test_memory_management(self): shape_names.append(f"box_{i}") # Process all shapes (should not run out of memory) - processor.process_shapes( - many_shapes, - shape_names, - self.temp_dir, - [ViewAngle.FRONT] - ) + processor.process_shapes(many_shapes, shape_names, self.temp_dir, [ViewAngle.FRONT]) # Check that some files were created - created_files = [f for f in os.listdir(self.temp_dir) if f.endswith('.png')] + created_files = [f for f in os.listdir(self.temp_dir) if f.endswith(".png")] self.assertGreater(len(created_files), 0) except Exception as e: @@ -401,5 +359,5 @@ def test_memory_management(self): self.skipTest(f"Display system not available: {e}") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/shape_test.py b/tests/src/pyocc/shape_test.py index 23db3a6..4b47a50 100644 --- a/tests/src/pyocc/shape_test.py +++ b/tests/src/pyocc/shape_test.py @@ -1,13 +1,16 @@ """ Test cases for the Shape base class. """ + import unittest + import numpy as np -from pyocc.solid import Solid -from pyocc.shape import Shape -from pyocc.vertex import Vertex + from pyocc.edge import Edge from pyocc.face import Face +from pyocc.shape import Shape +from pyocc.solid import Solid +from pyocc.vertex import Vertex class TestShape(unittest.TestCase): @@ -33,7 +36,7 @@ def test_shape_validity(self): def test_shape_hash_and_equality(self): """Test shape hashing and equality.""" box1 = Solid.make_box(1.0, 2.0, 3.0) - box2 = Solid.make_box(1.0, 2.0, 3.0) + Solid.make_box(1.0, 2.0, 3.0) # Different objects should have different hashes # (unless they're the exact same TopoDS_Shape) @@ -43,6 +46,7 @@ def test_shape_hash_and_equality(self): def test_bounding_box(self): """Test bounding box calculation.""" from pyocc.geometry.box import Box + bbox = self.box.box() self.assertIsInstance(bbox, Box) @@ -53,6 +57,7 @@ def test_bounding_box(self): def test_exact_bounding_box(self): """Test exact bounding box calculation.""" from pyocc.geometry.box import Box + bbox = self.box.exact_box() self.assertIsInstance(bbox, Box) @@ -137,7 +142,7 @@ def test_surface_properties(self): self.assertGreater(area, 0) # Box with dimensions 1x2x3 should have surface area 2*(1*2 + 1*3 + 2*3) = 22 - expected_area = 2 * (1*2 + 1*3 + 2*3) + expected_area = 2 * (1 * 2 + 1 * 3 + 2 * 3) self.assertAlmostEqual(area, expected_area, places=5) def test_volume_properties(self): @@ -184,11 +189,11 @@ def test_topological_queries(self): def test_file_io(self): """Test file I/O operations.""" - import tempfile import os + import tempfile # Test native OCC format - with tempfile.NamedTemporaryFile(suffix='.brep', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".brep", delete=False) as f: temp_file = f.name try: @@ -201,5 +206,5 @@ def test_file_io(self): os.unlink(temp_file) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/shell_test.py b/tests/src/pyocc/shell_test.py index 8fe8f94..9f734dc 100644 --- a/tests/src/pyocc/shell_test.py +++ b/tests/src/pyocc/shell_test.py @@ -1,17 +1,18 @@ """ Tests for the shell.py module containing Shell implementation. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Shell -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeBox from OCC.Core.BRep import BRep_Builder -from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeSphere from OCC.Core.TopAbs import TopAbs_SHELL +from OCC.Core.TopExp import TopExp_Explorer +from OCC.Core.TopoDS import TopoDS_Shell -from pyocc.shell import Shell from pyocc.face import Face -from pyocc.solid import Solid +from pyocc.shell import Shell class ShellTest(unittest.TestCase): @@ -110,5 +111,5 @@ def test_string_representation(self): self.assertTrue("open" in open_str) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/solid_test.py b/tests/src/pyocc/solid_test.py index 73443eb..74e5ae1 100644 --- a/tests/src/pyocc/solid_test.py +++ b/tests/src/pyocc/solid_test.py @@ -1,21 +1,18 @@ """ Tests for the solid.py module containing Solid implementation. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Solid -from OCC.Core.BRepPrimAPI import ( - BRepPrimAPI_MakeBox, BRepPrimAPI_MakeCylinder, - BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeCone -) -from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax2 +from OCC.Core.gp import gp_Pnt -from pyocc.solid import Solid -from pyocc.shell import Shell -from pyocc.face import Face from pyocc.edge import Edge -from pyocc.vertex import Vertex +from pyocc.face import Face from pyocc.geometry.box import Box +from pyocc.shell import Shell +from pyocc.solid import Solid +from pyocc.vertex import Vertex class SolidTest(unittest.TestCase): @@ -97,7 +94,7 @@ def test_volume_properties(self): # Sphere volume = (4/3) * π * r^3 sphere_volume = self.sphere.volume() - expected_sphere_volume = (4/3) * np.pi * 10**3 + expected_sphere_volume = (4 / 3) * np.pi * 10**3 self.assertAlmostEqual(sphere_volume, expected_sphere_volume, places=1) # Sphere center of mass should be at the center @@ -170,5 +167,5 @@ def test_string_representation(self): self.assertTrue("volume=" in sphere_str) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/tests/src/pyocc/test_uvgrid_simple.py b/tests/src/pyocc/test_uvgrid_simple.py index 6dab4fc..678e92b 100644 --- a/tests/src/pyocc/test_uvgrid_simple.py +++ b/tests/src/pyocc/test_uvgrid_simple.py @@ -1,10 +1,13 @@ """ Simple test cases for UV grid functionality. """ + import unittest + import numpy as np + from pyocc.solid import Solid -from pyocc.uvgrid import UniformGrid, sample_face_uniformly, sample_edge_uniformly +from pyocc.uvgrid import UniformGrid, sample_edge_uniformly, sample_face_uniformly class TestUVGridSimple(unittest.TestCase): @@ -41,7 +44,7 @@ def test_grid_point_generation(self): self.assertIsInstance(points, np.ndarray) self.assertEqual(points.shape[0], 25) # 5x5 grid - self.assertEqual(points.shape[1], 3) # 3D points + self.assertEqual(points.shape[1], 3) # 3D points def test_grid_parameters(self): """Test grid parameter access.""" @@ -82,12 +85,12 @@ def test_face_uniform_sampling(self): sample_data = sample_face_uniformly(self.test_face, num_u=4, num_v=4) self.assertIsInstance(sample_data, dict) - self.assertIn('points', sample_data) - self.assertIn('normals', sample_data) - self.assertIn('parameters', sample_data) + self.assertIn("points", sample_data) + self.assertIn("normals", sample_data) + self.assertIn("parameters", sample_data) - points = sample_data['points'] - normals = sample_data['normals'] + points = sample_data["points"] + normals = sample_data["normals"] self.assertEqual(points.shape[0], 16) # 4x4 sampling self.assertEqual(normals.shape[0], 16) @@ -102,12 +105,12 @@ def test_edge_uniform_sampling(self): sample_data = sample_edge_uniformly(self.test_edge, num_points=10) self.assertIsInstance(sample_data, dict) - self.assertIn('points', sample_data) - self.assertIn('tangents', sample_data) - self.assertIn('parameters', sample_data) + self.assertIn("points", sample_data) + self.assertIn("tangents", sample_data) + self.assertIn("parameters", sample_data) - points = sample_data['points'] - tangents = sample_data['tangents'] + points = sample_data["points"] + tangents = sample_data["tangents"] self.assertEqual(points.shape[0], 10) self.assertEqual(tangents.shape[0], 10) @@ -176,13 +179,13 @@ def test_edge_parameter_consistency(self): # Sample with different point counts for num_points in [5, 10, 20]: sample_data = sample_edge_uniformly(self.test_edge, num_points=num_points) - parameters = sample_data['parameters'] + parameters = sample_data["parameters"] self.assertEqual(len(parameters), num_points) # Parameters should be in ascending order for i in range(1, len(parameters)): - self.assertGreaterEqual(parameters[i], parameters[i-1]) + self.assertGreaterEqual(parameters[i], parameters[i - 1]) def test_sampling_edge_cases(self): """Test edge cases in sampling.""" @@ -207,7 +210,7 @@ def test_sampling_data_consistency(self): # Use function sampling func_data = sample_face_uniformly(self.test_face, num_u=3, num_v=3) - func_points = func_data['points'] + func_points = func_data["points"] # Should produce the same number of points self.assertEqual(grid_points.shape[0], func_points.shape[0]) @@ -223,7 +226,7 @@ def test_normal_vector_properties(self): self.skipTest("No face available for testing") sample_data = sample_face_uniformly(self.test_face, num_u=4, num_v=4) - normals = sample_data['normals'] + normals = sample_data["normals"] # All normals should be unit vectors lengths = np.linalg.norm(normals, axis=1) @@ -239,7 +242,7 @@ def test_tangent_vector_properties(self): self.skipTest("No edge available for testing") sample_data = sample_edge_uniformly(self.test_edge, num_points=10) - tangents = sample_data['tangents'] + tangents = sample_data["tangents"] # All tangents should be unit vectors lengths = np.linalg.norm(tangents, axis=1) @@ -252,7 +255,7 @@ def test_parameter_range_validation(self): self.skipTest("No edge available for testing") sample_data = sample_edge_uniformly(self.test_edge, num_points=20) - parameters = sample_data['parameters'] + parameters = sample_data["parameters"] # Parameters should be within [0, 1] range (normalized) # or within the edge's natural parameter range @@ -281,5 +284,5 @@ def test_memory_efficiency(self): self.assertTrue(np.all(np.isfinite(normals))) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/tri_utils_test.py b/tests/src/pyocc/tri_utils_test.py index 5a40ad1..ad2ab23 100644 --- a/tests/src/pyocc/tri_utils_test.py +++ b/tests/src/pyocc/tri_utils_test.py @@ -1,23 +1,26 @@ """ Test cases for triangle utilities. """ + import unittest + import numpy as np + from pyocc.geometry.tri_utils import ( - compute_triangle_normal, + barycentric_coordinates_2d, + barycentric_coordinates_3d, + compute_mesh_normals, compute_triangle_area, compute_triangle_centroid, + compute_triangle_normal, + decimate_triangle_mesh, point_in_triangle_2d, point_in_triangle_3d, - barycentric_coordinates_2d, - barycentric_coordinates_3d, - triangle_quality_metrics, + smooth_triangle_mesh, subdivide_triangle, triangle_mesh_adjacency, - smooth_triangle_mesh, - decimate_triangle_mesh, - compute_mesh_normals, - validate_triangle_mesh + triangle_quality_metrics, + validate_triangle_mesh, ) @@ -27,38 +30,22 @@ class TestTriUtils(unittest.TestCase): def setUp(self): """Set up test fixtures.""" # Simple triangle in xy-plane - self.triangle_2d = np.array([ - [0.0, 0.0], - [2.0, 0.0], - [1.0, 2.0] - ]) + self.triangle_2d = np.array([[0.0, 0.0], [2.0, 0.0], [1.0, 2.0]]) # 3D triangle - self.triangle_3d = np.array([ - [0.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [1.0, 2.0, 0.0] - ]) + self.triangle_3d = np.array([[0.0, 0.0, 0.0], [2.0, 0.0, 0.0], [1.0, 2.0, 0.0]]) # Simple triangle mesh (2 triangles forming a quad) - self.mesh_vertices = np.array([ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1.0, 0.0], - [0.0, 1.0, 0.0] - ]) - - self.mesh_triangles = np.array([ - [0, 1, 2], - [0, 2, 3] - ]) + self.mesh_vertices = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] + ) + + self.mesh_triangles = np.array([[0, 1, 2], [0, 2, 3]]) def test_compute_triangle_normal(self): """Test triangle normal computation.""" normal = compute_triangle_normal( - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) # Should be unit vector pointing in +z direction @@ -67,11 +54,7 @@ def test_compute_triangle_normal(self): def test_compute_triangle_area(self): """Test triangle area computation.""" - area = compute_triangle_area( - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] - ) + area = compute_triangle_area(self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2]) # Triangle with base=2, height=2, so area = 0.5 * 2 * 2 = 2 expected_area = 2.0 @@ -80,42 +63,35 @@ def test_compute_triangle_area(self): def test_compute_triangle_centroid(self): """Test triangle centroid computation.""" centroid = compute_triangle_centroid( - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) # Centroid should be at (1, 2/3, 0) - expected = np.array([1.0, 2.0/3.0, 0.0]) + expected = np.array([1.0, 2.0 / 3.0, 0.0]) np.testing.assert_array_almost_equal(centroid, expected, decimal=10) def test_point_in_triangle_2d(self): """Test 2D point-in-triangle test.""" # Test point inside triangle inside_point = np.array([1.0, 0.5]) - self.assertTrue(point_in_triangle_2d( - inside_point, - self.triangle_2d[0], - self.triangle_2d[1], - self.triangle_2d[2] - )) + self.assertTrue( + point_in_triangle_2d( + inside_point, self.triangle_2d[0], self.triangle_2d[1], self.triangle_2d[2] + ) + ) # Test point outside triangle outside_point = np.array([3.0, 0.5]) - self.assertFalse(point_in_triangle_2d( - outside_point, - self.triangle_2d[0], - self.triangle_2d[1], - self.triangle_2d[2] - )) + self.assertFalse( + point_in_triangle_2d( + outside_point, self.triangle_2d[0], self.triangle_2d[1], self.triangle_2d[2] + ) + ) # Test point on vertex vertex_point = self.triangle_2d[0] result = point_in_triangle_2d( - vertex_point, - self.triangle_2d[0], - self.triangle_2d[1], - self.triangle_2d[2] + vertex_point, self.triangle_2d[0], self.triangle_2d[1], self.triangle_2d[2] ) # Should be True (on boundary) self.assertTrue(result) @@ -124,21 +100,17 @@ def test_point_in_triangle_3d(self): """Test 3D point-in-triangle test.""" # Test point inside triangle (projected) inside_point = np.array([1.0, 0.5, 0.0]) - self.assertTrue(point_in_triangle_3d( - inside_point, - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] - )) + self.assertTrue( + point_in_triangle_3d( + inside_point, self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] + ) + ) # Test point outside triangle plane outside_plane_point = np.array([1.0, 0.5, 1.0]) # Should handle projection to triangle plane result = point_in_triangle_3d( - outside_plane_point, - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + outside_plane_point, self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) self.assertIsInstance(result, bool) @@ -147,13 +119,10 @@ def test_barycentric_coordinates_2d(self): # Test centroid (should have coordinates [1/3, 1/3, 1/3]) centroid = np.mean(self.triangle_2d, axis=0) bary_coords = barycentric_coordinates_2d( - centroid, - self.triangle_2d[0], - self.triangle_2d[1], - self.triangle_2d[2] + centroid, self.triangle_2d[0], self.triangle_2d[1], self.triangle_2d[2] ) - expected = np.array([1.0/3.0, 1.0/3.0, 1.0/3.0]) + expected = np.array([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]) np.testing.assert_array_almost_equal(bary_coords, expected, decimal=10) # Test that coordinates sum to 1 @@ -164,50 +133,43 @@ def test_barycentric_coordinates_3d(self): # Test centroid centroid = np.mean(self.triangle_3d, axis=0) bary_coords = barycentric_coordinates_3d( - centroid, - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + centroid, self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) - expected = np.array([1.0/3.0, 1.0/3.0, 1.0/3.0]) + expected = np.array([1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]) np.testing.assert_array_almost_equal(bary_coords, expected, decimal=10) def test_triangle_quality_metrics(self): """Test triangle quality metric computation.""" metrics = triangle_quality_metrics( - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) self.assertIsInstance(metrics, dict) - self.assertIn('aspect_ratio', metrics) - self.assertIn('area', metrics) - self.assertIn('perimeter', metrics) - self.assertIn('angles', metrics) + self.assertIn("aspect_ratio", metrics) + self.assertIn("area", metrics) + self.assertIn("perimeter", metrics) + self.assertIn("angles", metrics) # Check that aspect ratio is positive - self.assertGreater(metrics['aspect_ratio'], 0.0) + self.assertGreater(metrics["aspect_ratio"], 0.0) # Check that angles sum to π - angles = metrics['angles'] + angles = metrics["angles"] self.assertAlmostEqual(np.sum(angles), np.pi, places=5) def test_subdivide_triangle(self): """Test triangle subdivision.""" subdivided = subdivide_triangle( - self.triangle_3d[0], - self.triangle_3d[1], - self.triangle_3d[2] + self.triangle_3d[0], self.triangle_3d[1], self.triangle_3d[2] ) self.assertIsInstance(subdivided, dict) - self.assertIn('vertices', subdivided) - self.assertIn('triangles', subdivided) + self.assertIn("vertices", subdivided) + self.assertIn("triangles", subdivided) - vertices = subdivided['vertices'] - triangles = subdivided['triangles'] + vertices = subdivided["vertices"] + triangles = subdivided["triangles"] # Should create 4 triangles from 1 self.assertEqual(triangles.shape[0], 4) @@ -230,9 +192,7 @@ def test_triangle_mesh_adjacency(self): def test_smooth_triangle_mesh(self): """Test triangle mesh smoothing.""" smoothed_vertices = smooth_triangle_mesh( - self.mesh_vertices, - self.mesh_triangles, - iterations=1 + self.mesh_vertices, self.mesh_triangles, iterations=1 ) self.assertIsInstance(smoothed_vertices, np.ndarray) @@ -244,34 +204,27 @@ def test_smooth_triangle_mesh(self): def test_decimate_triangle_mesh(self): """Test triangle mesh decimation.""" # Create a mesh with more triangles for meaningful decimation - dense_vertices = np.array([ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0], - [0.0, 1.0, 0.0], - [1.0, 1.0, 0.0], - [2.0, 1.0, 0.0] - ]) - - dense_triangles = np.array([ - [0, 1, 3], - [1, 4, 3], - [1, 2, 4], - [2, 5, 4] - ]) - - decimated = decimate_triangle_mesh( - dense_vertices, - dense_triangles, - target_reduction=0.5 + dense_vertices = np.array( + [ + [0.0, 0.0, 0.0], + [1.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [2.0, 1.0, 0.0], + ] ) + dense_triangles = np.array([[0, 1, 3], [1, 4, 3], [1, 2, 4], [2, 5, 4]]) + + decimated = decimate_triangle_mesh(dense_vertices, dense_triangles, target_reduction=0.5) + self.assertIsInstance(decimated, dict) - self.assertIn('vertices', decimated) - self.assertIn('triangles', decimated) + self.assertIn("vertices", decimated) + self.assertIn("triangles", decimated) # Should have fewer triangles than original - new_triangles = decimated['triangles'] + new_triangles = decimated["triangles"] self.assertLessEqual(len(new_triangles), len(dense_triangles)) def test_compute_mesh_normals(self): @@ -281,14 +234,14 @@ def test_compute_mesh_normals(self): self.assertIsInstance(normals, dict) # Should contain face normals - if 'face_normals' in normals: - face_normals = normals['face_normals'] + if "face_normals" in normals: + face_normals = normals["face_normals"] self.assertEqual(face_normals.shape[0], len(self.mesh_triangles)) self.assertEqual(face_normals.shape[1], 3) # Should contain vertex normals - if 'vertex_normals' in normals: - vertex_normals = normals['vertex_normals'] + if "vertex_normals" in normals: + vertex_normals = normals["vertex_normals"] self.assertEqual(vertex_normals.shape[0], len(self.mesh_vertices)) self.assertEqual(vertex_normals.shape[1], 3) @@ -298,42 +251,31 @@ def test_validate_triangle_mesh(self): validation = validate_triangle_mesh(self.mesh_vertices, self.mesh_triangles) self.assertIsInstance(validation, dict) - self.assertIn('is_valid', validation) - self.assertTrue(validation['is_valid']) + self.assertIn("is_valid", validation) + self.assertTrue(validation["is_valid"]) # Test invalid mesh (triangle with invalid vertex index) - invalid_triangles = np.array([ - [0, 1, 2], - [0, 2, 10] # Vertex 10 doesn't exist - ]) + invalid_triangles = np.array([[0, 1, 2], [0, 2, 10]]) # Vertex 10 doesn't exist invalid_validation = validate_triangle_mesh(self.mesh_vertices, invalid_triangles) - self.assertFalse(invalid_validation['is_valid']) - self.assertIn('errors', invalid_validation) + self.assertFalse(invalid_validation["is_valid"]) + self.assertIn("errors", invalid_validation) def test_degenerate_triangles(self): """Test handling of degenerate triangles.""" # Triangle with zero area (all points collinear) - degenerate_triangle = np.array([ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [2.0, 0.0, 0.0] - ]) + degenerate_triangle = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]) # Area should be zero or very small area = compute_triangle_area( - degenerate_triangle[0], - degenerate_triangle[1], - degenerate_triangle[2] + degenerate_triangle[0], degenerate_triangle[1], degenerate_triangle[2] ) self.assertAlmostEqual(area, 0.0, places=10) # Normal computation should handle gracefully try: normal = compute_triangle_normal( - degenerate_triangle[0], - degenerate_triangle[1], - degenerate_triangle[2] + degenerate_triangle[0], degenerate_triangle[1], degenerate_triangle[2] ) # Should either return zero vector or handle gracefully self.assertIsInstance(normal, np.ndarray) @@ -343,47 +285,25 @@ def test_degenerate_triangles(self): def test_triangle_edge_cases(self): """Test edge cases in triangle computations.""" # Very small triangle - tiny_triangle = np.array([ - [0.0, 0.0, 0.0], - [1e-10, 0.0, 0.0], - [0.0, 1e-10, 0.0] - ]) + tiny_triangle = np.array([[0.0, 0.0, 0.0], [1e-10, 0.0, 0.0], [0.0, 1e-10, 0.0]]) - area = compute_triangle_area( - tiny_triangle[0], - tiny_triangle[1], - tiny_triangle[2] - ) + area = compute_triangle_area(tiny_triangle[0], tiny_triangle[1], tiny_triangle[2]) self.assertGreaterEqual(area, 0.0) # Very large triangle - large_triangle = np.array([ - [0.0, 0.0, 0.0], - [1e10, 0.0, 0.0], - [0.0, 1e10, 0.0] - ]) + large_triangle = np.array([[0.0, 0.0, 0.0], [1e10, 0.0, 0.0], [0.0, 1e10, 0.0]]) - area = compute_triangle_area( - large_triangle[0], - large_triangle[1], - large_triangle[2] - ) + area = compute_triangle_area(large_triangle[0], large_triangle[1], large_triangle[2]) self.assertGreater(area, 0.0) def test_numerical_precision(self): """Test numerical precision in triangle computations.""" # Test with nearly degenerate triangle - nearly_degenerate = np.array([ - [0.0, 0.0, 0.0], - [1.0, 0.0, 0.0], - [1.0, 1e-12, 0.0] - ]) + nearly_degenerate = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1e-12, 0.0]]) # Should handle without numerical issues area = compute_triangle_area( - nearly_degenerate[0], - nearly_degenerate[1], - nearly_degenerate[2] + nearly_degenerate[0], nearly_degenerate[1], nearly_degenerate[2] ) self.assertGreaterEqual(area, 0.0) @@ -391,10 +311,7 @@ def test_numerical_precision(self): point = np.array([0.5, 0.0]) # On the base edge try: bary_coords = barycentric_coordinates_2d( - point, - nearly_degenerate[0][:2], - nearly_degenerate[1][:2], - nearly_degenerate[2][:2] + point, nearly_degenerate[0][:2], nearly_degenerate[1][:2], nearly_degenerate[2][:2] ) # For nearly degenerate triangles, we may get NaN values if not np.any(np.isnan(bary_coords)): @@ -407,17 +324,15 @@ def test_mesh_operations_consistency(self): # Compute normals and check consistency normals = compute_mesh_normals(self.mesh_vertices, self.mesh_triangles) - if 'face_normals' in normals: - face_normals = normals['face_normals'] + if "face_normals" in normals: + face_normals = normals["face_normals"] # Manually compute normal for first triangle and compare v0, v1, v2 = [self.mesh_vertices[i] for i in self.mesh_triangles[0]] manual_normal = compute_triangle_normal(v0, v1, v2) - np.testing.assert_array_almost_equal( - face_normals[0], manual_normal, decimal=10 - ) + np.testing.assert_array_almost_equal(face_normals[0], manual_normal, decimal=10) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/uvgrid_test.py b/tests/src/pyocc/uvgrid_test.py index fb80eed..d561a00 100644 --- a/tests/src/pyocc/uvgrid_test.py +++ b/tests/src/pyocc/uvgrid_test.py @@ -1,18 +1,15 @@ """ Tests for the uvgrid module. """ + import unittest + import numpy as np -import os -import tempfile -from OCC.Core.TopoDS import TopoDS_Face -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeSphere, BRepPrimAPI_MakeCylinder -from OCC.Core.gp import gp_Pnt, gp_Dir, gp_Ax2 +from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakeSphere +from OCC.Core.gp import gp_Ax2, gp_Dir, gp_Pnt -from pyocc.face import Face from pyocc.solid import Solid -from pyocc.edge import Edge -from pyocc.uvgrid import uvgrid, ugrid, uvgrid_points, uvgrid_normals, ugrid_points, ugrid_tangents +from pyocc.uvgrid import ugrid, ugrid_points, ugrid_tangents, uvgrid, uvgrid_normals, uvgrid_points class UVGridTest(unittest.TestCase): @@ -27,7 +24,9 @@ def setUp(self): self.sphere_face = list(solid.faces())[0] # Get the first face # Create a cylinder for testing - cylinder_maker = BRepPrimAPI_MakeCylinder(gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), 5.0, 20.0) + cylinder_maker = BRepPrimAPI_MakeCylinder( + gp_Ax2(gp_Pnt(0, 0, 0), gp_Dir(0, 0, 1)), 5.0, 20.0 + ) cylinder_maker.Build() solid = Solid(cylinder_maker.Solid()) self.cylinder_face = list(solid.faces())[0] # Get the lateral face @@ -94,6 +93,7 @@ def test_ugrid_with_us(self): self.assertEqual(points_array.shape[0], u_array.shape[0]) self.assertEqual(points_array.shape, (15, 3)) self.assertEqual(u_array.shape, (15,)) + def test_different_face_types(self): """Test UV grid generation on different face types.""" # Test on cylinder face @@ -105,7 +105,7 @@ def test_different_face_types(self): for j in range(10): point = points_grid[i, j] if not np.allclose(point, [0.0, 0.0, 0.0]): # Skip default points - radius = np.sqrt(point[0]**2 + point[1]**2) + radius = np.sqrt(point[0] ** 2 + point[1] ** 2) self.assertAlmostEqual(radius, 5.0, places=1) def test_grid_resolutions(self): @@ -180,5 +180,5 @@ def test_method_flexibility(self): pass -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/vertex_test.py b/tests/src/pyocc/vertex_test.py index 03e1d3d..6640096 100644 --- a/tests/src/pyocc/vertex_test.py +++ b/tests/src/pyocc/vertex_test.py @@ -1,14 +1,15 @@ """ Tests for the vertex.py module containing Vertex implementation. """ + import unittest + import numpy as np -from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeVertex from OCC.Core.gp import gp_Pnt -from pyocc.vertex import Vertex from pyocc.solid import Solid +from pyocc.vertex import Vertex class VertexTest(unittest.TestCase): @@ -70,5 +71,5 @@ def test_string_representation(self): self.assertTrue("Vertex(10.000, 20.000, 30.000)" in str(self.other_vertex)) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/viewer_test.py b/tests/src/pyocc/viewer_test.py index 3b93f6f..6737309 100644 --- a/tests/src/pyocc/viewer_test.py +++ b/tests/src/pyocc/viewer_test.py @@ -1,13 +1,16 @@ """ Test cases for the Viewer and visualization system. """ + +import os +import tempfile import unittest + import numpy as np -import tempfile -import os + +from pyocc.display_data import get_shape_display_data from pyocc.solid import Solid from pyocc.viewer import OffscreenRenderer -from pyocc.display_data import get_shape_display_data class TestViewer(unittest.TestCase): @@ -74,7 +77,7 @@ def test_image_export(self): renderer.fit() # Export to temporary file - with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: temp_file = f.name try: @@ -101,9 +104,7 @@ def test_lighting_controls(self): # Test lighting renderer.add_directional_light( - direction=np.array([1.0, 1.0, -1.0]), - color=(1.0, 1.0, 1.0), - intensity=1.0 + direction=np.array([1.0, 1.0, -1.0]), color=(1.0, 1.0, 1.0), intensity=1.0 ) # Test antialiasing @@ -141,11 +142,11 @@ def test_display_data_extraction(self): display_data = get_shape_display_data(self.box) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) - vertices = display_data['vertices'] - triangles = display_data['triangles'] + vertices = display_data["vertices"] + triangles = display_data["triangles"] self.assertIsInstance(vertices, np.ndarray) self.assertIsInstance(triangles, np.ndarray) @@ -154,18 +155,14 @@ def test_display_data_extraction(self): def test_display_data_with_colors(self): """Test display data extraction with colors.""" - display_data = get_shape_display_data( - self.sphere, - color=(0.8, 0.2, 0.5), - transparency=0.3 - ) + display_data = get_shape_display_data(self.sphere, color=(0.8, 0.2, 0.5), transparency=0.3) self.assertIsInstance(display_data, dict) - self.assertIn('vertices', display_data) - self.assertIn('triangles', display_data) + self.assertIn("vertices", display_data) + self.assertIn("triangles", display_data) - if 'colors' in display_data: - colors = display_data['colors'] + if "colors" in display_data: + colors = display_data["colors"] self.assertIsInstance(colors, np.ndarray) def test_multiple_shapes_display(self): @@ -211,5 +208,5 @@ def test_viewer_error_handling(self): self.skipTest(f"Display system not available: {e}") -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/src/pyocc/wire_test.py b/tests/src/pyocc/wire_test.py index 2df9dab..02064f1 100644 --- a/tests/src/pyocc/wire_test.py +++ b/tests/src/pyocc/wire_test.py @@ -1,16 +1,17 @@ """ Tests for the wire.py module containing Wire implementation. """ + import unittest + import numpy as np -from OCC.Core.TopoDS import TopoDS_Wire -from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeWire, BRepBuilderAPI_MakeEdge -from OCC.Core.gp import gp_Pnt, gp_Circ, gp_XOY +from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeWire from OCC.Core.GC import GC_MakeSegment +from OCC.Core.gp import gp_Pnt -from pyocc.wire import Wire from pyocc.edge import Edge from pyocc.solid import Solid +from pyocc.wire import Wire class WireTest(unittest.TestCase): @@ -87,11 +88,11 @@ def test_point_and_tangent(self): """Test point and tangent calculation.""" # We'll test this using the closed wire which is more interesting # Get the midpoint of each segment - params = [0.0, 1/3, 2/3] + params = [0.0, 1 / 3, 2 / 3] expected_points = [ [0, 0, 0], # Start point [10, 0, 0], # Second vertex - [5, 10, 0] # Third vertex + [5, 10, 0], # Third vertex ] for i, param in enumerate(params): @@ -134,5 +135,5 @@ def test_string_representation(self): self.assertTrue("closed" in str(self.closed_wire)) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() From 9406d0659587a9d7514c92599047cf97882a6dc9 Mon Sep 17 00:00:00 2001 From: LayerDynamics Date: Wed, 10 Jun 2026 10:19:56 -0500 Subject: [PATCH 2/2] Fix CI markdown lint: scan only git-tracked markdown The check.yml step listed CLAUDE.md explicitly, but CLAUDE.md is gitignored, so a fresh CI checkout has no such file and PyMarkdown failed with "path does not exist" (exit 1). Scan `git ls-files '*.md'` instead so ignored files are never referenced; updated the documented command to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/check.yml | 4 +++- CONTRIBUTING.md | 2 +- docs/developer_guide.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0d341a6..1c5e221 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -46,7 +46,9 @@ jobs: run: black --check src tests examples - name: Markdown lint (PyMarkdown) - run: python -m pymarkdown scan README.md CLAUDE.md CONTRIBUTING.md docs examples + # Scan exactly the git-tracked markdown so ignored files (e.g. a local + # CLAUDE.md) are never referenced and can't break the run. + run: git ls-files '*.md' | xargs python -m pymarkdown scan # mypy stays informational for now: the OCC-heavy code carries type debt # and the project's mypy config is intentionally lenient. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e47d632..ebddb64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ A change is not complete until `pytest` passes with no new failures. ```bash ruff check src tests # lint + import order (add --fix to sort imports) black src tests # format (line length 100) -pymarkdown scan README.md docs examples # markdown lint (rules in pyproject) +git ls-files '*.md' | xargs pymarkdown scan # markdown lint (rules in pyproject) mypy src/pyocc # type check (lenient; OCC.* is ignored) ``` diff --git a/docs/developer_guide.md b/docs/developer_guide.md index d14e5b7..df8fa38 100644 --- a/docs/developer_guide.md +++ b/docs/developer_guide.md @@ -32,7 +32,7 @@ pytest --cov=pyocc tests/ # coverage ruff check src tests # lint + import order (add --fix to sort imports) black src tests # format (line length 100) -pymarkdown scan README.md docs examples # markdown lint (rules in pyproject) +git ls-files '*.md' | xargs pymarkdown scan # markdown lint (rules in pyproject) mypy src/pyocc # type check (lenient; OCC.* ignored) ```