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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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')"
66 changes: 66 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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)
# 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.
- 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
62 changes: 62 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@ dmypy.json
# Logs
logs/
*.log
CLAUDE.md
11 changes: 11 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -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/**"
]
}
9 changes: 9 additions & 0 deletions .markdownlint.jsonc
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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)
git ls-files '*.md' | xargs pymarkdown scan # 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).
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading