diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1cd9560..660e066 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -3,14 +3,19 @@ name: Python package on: push: branches: [ master ] + tags: ['*'] pull_request: branches: [ master ] - create: - tags: workflow_dispatch: jobs: + debug: + name: Debug + runs-on: ubuntu-latest + steps: + - uses: hmarr/debug-action@v3 + test: name: Test runs-on: ${{ matrix.os }} @@ -19,9 +24,9 @@ jobs: python-version: ['3.9', '3.10', '3.11', '3.12'] os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -40,14 +45,14 @@ jobs: name: Build wheels needs: [test] runs-on: ${{ matrix.os }} - if: github.event_name == 'create' && startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: '3.9' - name: Install dependencies @@ -59,20 +64,20 @@ jobs: env: CIBW_BUILD: cp39-* cp310-* cp311-* cp312-* CIBW_SKIP: '*musllinux* *i686*' - # CIBW_BEFORE_BUILD: pip install -r requirements.dev.txt - uses: actions/upload-artifact@v3 with: + name: wheels-${{ matrix.os }} path: ./wheelhouse/*.whl build_sdist: name: Build sdist needs: [test] runs-on: ubuntu-latest - if: github.event_name == 'create' && startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: '3.9' - name: Install dependencies @@ -80,20 +85,21 @@ jobs: python -m pip install --upgrade pip wheel poetry - name: Build sdist run: make sdist - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: + name: sdist path: ./dist/*.tar.gz deploy: name: Deploy needs: [build_wheels, build_sdist] runs-on: ubuntu-latest - if: github.event_name == 'create' && startsWith(github.ref, 'refs/tags/v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: artifact path: dist + merge-multiple: true - uses: pypa/gh-action-pypi-publish@v1.4.1 with: user: __token__ @@ -105,10 +111,10 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: artifact path: dist + merge-multiple: true - uses: pypa/gh-action-pypi-publish@v1.4.1 with: user: __token__ diff --git a/Makefile b/Makefile index aa6aac8..de4aecc 100644 --- a/Makefile +++ b/Makefile @@ -1,36 +1,47 @@ -.PHONY: install mypy lint fmt fmtcheck doc +package := splipy -install: - poetry install --with=dev -mypy: - poetry run mypy splipy +# Convenience targets -lint: - poetry run ruff splipy +.PHONY: install +install: + poetry install --with=dev -bench: - poetry run pytest --benchmark-only +.PHONY: doc +doc: + $(MAKE) -C doc html -fmt: - poetry run black splipy - poetry run isort splipy -fmtcheck: - poetry run black splipy --check - poetry run isort splipy --check +# Linting targets +.PHONY: format +format: + poetry run ruff format $(package) -doc: - $(MAKE) -C doc html +.PHONY: lint +lint: + poetry run ruff check --fix $(package) # Test targets +.PHONY: +benchmark: + poetry run pytest --benchmark-only + .PHONY: pytest pytest: poetry run pytest --benchmark-skip +.PHONY: mypy +mypy: + poetry run mypy + +.PHONY: lint-check +lint-check: + poetry run ruff check $(package) + poetry run ruff format --check $(package) + .PHONY: examples examples: poetry run python examples/circle_animation.py --ci @@ -42,7 +53,7 @@ examples: poetry run python examples/write.py .PHONY: test # most common test commands for everyday development -test: pytest +test: pytest mypy lint-check .PHONY: test-all # run from CI: the whole kitchen sink test-all: test examples diff --git a/README.rst b/README.rst index e691229..a075b60 100644 --- a/README.rst +++ b/README.rst @@ -75,6 +75,30 @@ Don't upload wheels to PyPI manually. They are built by CI runners whenever a new version is tagged (see below). +Tests +----- + +To run the tests, use:: + + make test + +To run specific parts of the tests, use:: + + make pytest + make mypy + make lint-check + +The lint-check stage of the tests will complain about linter and style errors. +Some of these can be fixed automatically. To do this, run:: + + make lint + make format + +For benchmarks:: + + make benchmark + + Documentation ------------- @@ -89,18 +113,6 @@ To push generated docs online on the ``gh-pages`` branch, run the helper script: where ``remote`` is the name of the remote to push to. If not given, it will be asked. -Tests ------ - -To run the tests, use:: - - make test - -For benchmarks:: - - make bench - - Releasing --------- diff --git a/examples/write.py b/examples/write.py index 90947bc..c057c79 100644 --- a/examples/write.py +++ b/examples/write.py @@ -18,5 +18,5 @@ # G2 files are native GoTools (http://www.sintef.no/projectweb/geometry-toolkits/gotools/) -with G2('torus.g2') as my_file: +with G2('torus.g2', 'w') as my_file: my_file.write(torus) diff --git a/poetry.lock b/poetry.lock index 86483a1..af58b71 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,13 +38,13 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "bump-my-version" -version = "0.17.3" +version = "0.17.4" description = "Version bump your Python project" optional = false python-versions = ">=3.8" files = [ - {file = "bump-my-version-0.17.3.tar.gz", hash = "sha256:ac643cf4b36cb925e78c2c48628fd30d3f99d0d30d3d954725214813dde7283c"}, - {file = "bump_my_version-0.17.3-py3-none-any.whl", hash = "sha256:d07b33784922ad6acba19e31a62d0f56572e405430187bf9e7d9ab2d3e593921"}, + {file = "bump-my-version-0.17.4.tar.gz", hash = "sha256:ae03746773e5bda00512eba90328894b7cbe57c8782cde97e886ea3ddfdf517b"}, + {file = "bump_my_version-0.17.4-py3-none-any.whl", hash = "sha256:14bd0bfe6d21e4f8731a6c424b320625214dedd541033b57b04ddc5c5b30d1f5"}, ] [package.dependencies] @@ -272,6 +272,43 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "h5py" +version = "3.10.0" +description = "Read and write HDF5 files from Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "h5py-3.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b963fb772964fc1d1563c57e4e2e874022ce11f75ddc6df1a626f42bd49ab99f"}, + {file = "h5py-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:012ab448590e3c4f5a8dd0f3533255bc57f80629bf7c5054cf4c87b30085063c"}, + {file = "h5py-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:781a24263c1270a62cd67be59f293e62b76acfcc207afa6384961762bb88ea03"}, + {file = "h5py-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f42e6c30698b520f0295d70157c4e202a9e402406f50dc08f5a7bc416b24e52d"}, + {file = "h5py-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:93dd840bd675787fc0b016f7a05fc6efe37312a08849d9dd4053fd0377b1357f"}, + {file = "h5py-3.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2381e98af081b6df7f6db300cd88f88e740649d77736e4b53db522d8874bf2dc"}, + {file = "h5py-3.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:667fe23ab33d5a8a6b77970b229e14ae3bb84e4ea3382cc08567a02e1499eedd"}, + {file = "h5py-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90286b79abd085e4e65e07c1bd7ee65a0f15818ea107f44b175d2dfe1a4674b7"}, + {file = "h5py-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c013d2e79c00f28ffd0cc24e68665ea03ae9069e167087b2adb5727d2736a52"}, + {file = "h5py-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:92273ce69ae4983dadb898fd4d3bea5eb90820df953b401282ee69ad648df684"}, + {file = "h5py-3.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c97d03f87f215e7759a354460fb4b0d0f27001450b18b23e556e7856a0b21c3"}, + {file = "h5py-3.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86df4c2de68257b8539a18646ceccdcf2c1ce6b1768ada16c8dcfb489eafae20"}, + {file = "h5py-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba9ab36be991119a3ff32d0c7cbe5faf9b8d2375b5278b2aea64effbeba66039"}, + {file = "h5py-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c8e4fda19eb769e9a678592e67eaec3a2f069f7570c82d2da909c077aa94339"}, + {file = "h5py-3.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:492305a074327e8d2513011fa9fffeb54ecb28a04ca4c4227d7e1e9616d35641"}, + {file = "h5py-3.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9450464b458cca2c86252b624279115dcaa7260a40d3cb1594bf2b410a2bd1a3"}, + {file = "h5py-3.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd6f6d1384a9f491732cee233b99cd4bfd6e838a8815cc86722f9d2ee64032af"}, + {file = "h5py-3.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3074ec45d3dc6e178c6f96834cf8108bf4a60ccb5ab044e16909580352010a97"}, + {file = "h5py-3.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:212bb997a91e6a895ce5e2f365ba764debeaef5d2dca5c6fb7098d66607adf99"}, + {file = "h5py-3.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5dfc65ac21fa2f630323c92453cadbe8d4f504726ec42f6a56cf80c2f90d6c52"}, + {file = "h5py-3.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4682b94fd36ab217352be438abd44c8f357c5449b8995e63886b431d260f3d3"}, + {file = "h5py-3.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aece0e2e1ed2aab076c41802e50a0c3e5ef8816d60ece39107d68717d4559824"}, + {file = "h5py-3.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43a61b2c2ad65b1fabc28802d133eed34debcc2c8b420cb213d3d4ef4d3e2229"}, + {file = "h5py-3.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:ae2f0201c950059676455daf92700eeb57dcf5caaf71b9e1328e6e6593601770"}, + {file = "h5py-3.10.0.tar.gz", hash = "sha256:d93adc48ceeb33347eb24a634fb787efc7ae4644e6ea4ba733d099605045c049"}, +] + +[package.dependencies] +numpy = ">=1.17.3" + [[package]] name = "idna" version = "3.6" @@ -445,6 +482,64 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "mypy" +version = "1.8.0" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=4.1.0" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "numpy" version = "1.26.4" @@ -490,6 +585,52 @@ files = [ {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, ] +[[package]] +name = "nutils" +version = "4.1" +description = "Numerical Utilities for Finite Element Analysis" +optional = true +python-versions = ">=3.5" +files = [ + {file = "nutils-4.1-py3-none-any.whl", hash = "sha256:1766ddf694b8cb4a7f735b6a8c292c6f0f1f8511acffa59671b6f5ee5726b6dc"}, + {file = "nutils-4.1.tar.gz", hash = "sha256:4a93e0ea1cccff9a0bcf6f1e7600329c21df6f3ba3a88b13f779da4f006a0007"}, +] + +[package.dependencies] +numpy = ">=1.12" + +[package.extras] +docs = ["Sphinx (>=1.6)", "matplotlib (>=1.3)", "scipy (>=0.13)"] +export-mpl = ["matplotlib (>=1.3)", "pillow (>2.6)"] +matrix-mkl = ["mkl", "tbb"] +matrix-scipy = ["scipy (>=0.13)"] + +[[package]] +name = "opencv-python" +version = "4.9.0.80" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +files = [ + {file = "opencv-python-4.9.0.80.tar.gz", hash = "sha256:1a9f0e6267de3a1a1db0c54213d022c7c8b5b9ca4b580e80bdc58516c922c9e1"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-macosx_10_16_x86_64.whl", hash = "sha256:7e5f7aa4486651a6ebfa8ed4b594b65bd2d2f41beeb4241a3e4b1b85acbbbadb"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:71dfb9555ccccdd77305fc3dcca5897fbf0cf28b297c51ee55e079c065d812a3"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b34a52e9da36dda8c151c6394aed602e4b17fa041df0b9f5b93ae10b0fcca2a"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4088cab82b66a3b37ffc452976b14a3c599269c247895ae9ceb4066d8188a57"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-win32.whl", hash = "sha256:dcf000c36dd1651118a2462257e3a9e76db789a78432e1f303c7bac54f63ef6c"}, + {file = "opencv_python-4.9.0.80-cp37-abi3-win_amd64.whl", hash = "sha256:3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=1.21.4", markers = "python_version >= \"3.10\" and platform_system == \"Darwin\" and python_version < \"3.11\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version >= \"3.10\" and python_version < \"3.11\""}, + {version = ">=1.19.3", markers = "platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version >= \"3.8\" and python_version < \"3.10\" or python_version > \"3.9\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_system != \"Darwin\" and python_version < \"3.10\" or python_version >= \"3.9\" and platform_machine != \"arm64\" and python_version < \"3.10\""}, +] + [[package]] name = "packaging" version = "23.2" @@ -772,6 +913,30 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rhino3dm" +version = "8.0.1" +description = "Python library based on OpenNURBS with a RhinoCommon style" +optional = false +python-versions = "*" +files = [ + {file = "rhino3dm-8.0.1-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:d047a487039e44dd7464376c45865ed3792d5b8af36fcda4ad691eea9f051699"}, + {file = "rhino3dm-8.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:098d7701861340dca5fc843d294b3c70985f6bb2853adaf3c55aa8dc170b7426"}, + {file = "rhino3dm-8.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:3efa278c869096df17682e0909b512d84de1f58436bf9e59fce9ecc609549e68"}, + {file = "rhino3dm-8.0.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a846076f19c45177e6de3fc86b9c8068f843dd3f733be82b214d0af0c84da8c7"}, + {file = "rhino3dm-8.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946eac47f923e9d8aa944523641f0ac94120036a4a6d28430ab26d607a7996"}, + {file = "rhino3dm-8.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:42228674b0d27caa29d16dc2f7612dc2395bc8a79ec958962a2a61505e0e2083"}, + {file = "rhino3dm-8.0.1-cp37-cp37m-macosx_11_0_x86_64.whl", hash = "sha256:316570ae529181f02c03b67dc9f6e67b6ebd2bbbd290157da9d0953c461eced1"}, + {file = "rhino3dm-8.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:e2eddb16f4c4657af2eee539d78906e507079387f6766606759040b62bb94678"}, + {file = "rhino3dm-8.0.1-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:7343c0340fffb95caf1108125586df505aa393d1e3d2a6618f71839edcf2d3c3"}, + {file = "rhino3dm-8.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d46ffd223c4b326bacf17c6afcce7f5530f99fa56beb5192dd6752646b5c1644"}, + {file = "rhino3dm-8.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:82ce14a58ae1a804bf5658ffba6e23f42e0fa6ef08589e47e768ac4628ab04b8"}, + {file = "rhino3dm-8.0.1-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:86cb5814ca7bf22d6680949ba8843494a7a6d8cb6f370d40414c553eac58aa5a"}, + {file = "rhino3dm-8.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed95b7aa4391562deea513c2e693641c34b275e8bd1279355d2b6c272543e606"}, + {file = "rhino3dm-8.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:20f268f4e0563d6b0252d51eefab9654b198cf280b83586b6b787539f3e7ddf6"}, + {file = "rhino3dm-8.0.1.tar.gz", hash = "sha256:4f2db2eddc984090e35500d5b02a1896591334d4a8bb41d38f8b3b21c545d366"}, +] + [[package]] name = "rich" version = "13.7.0" @@ -809,6 +974,32 @@ typing-extensions = "*" [package.extras] dev = ["flake8", "flake8-docstrings", "mypy", "packaging", "pre-commit", "pytest", "pytest-cov", "types-setuptools"] +[[package]] +name = "ruff" +version = "0.2.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"}, + {file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"}, + {file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"}, + {file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"}, + {file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"}, + {file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"}, + {file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"}, + {file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"}, +] + [[package]] name = "scipy" version = "1.12.0" @@ -1013,6 +1204,26 @@ files = [ {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, ] +[[package]] +name = "tqdm" +version = "4.66.2" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.2-py3-none-any.whl", hash = "sha256:1ee4f8a893eb9bef51c6e35730cebf234d5d0b6bd112b0271e10ed7c24a02bd9"}, + {file = "tqdm-4.66.2.tar.gz", hash = "sha256:6cd52cdf0fef0e0f543299cfc96fec90d7b8a7e88745f411ec33eb44d5ed3531"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + [[package]] name = "typing-extensions" version = "4.9.0" @@ -1068,11 +1279,11 @@ docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.link testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] [extras] -finiteelement = [] -images = [] +finiteelement = ["nutils"] +images = ["opencv-python"] rhino = [] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "4b02786480562b703c8a610d875f3f2cb0a6690cb128d80aa7763b115bdee163" +content-hash = "ea4802f7114b5ecbe3f2cdc9b4803f2c6a3b242ec25f3f810ba1a1ae1ef73b78" diff --git a/pyproject.toml b/pyproject.toml index 34f9d8a..a12a193 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ generate-setup-file = true python = ">=3.9,<3.13" numpy = "^1.21" scipy = "^1.9" +opencv-python = { version = "^4", optional = true} +nutils = { version = "^4", optional = true } [tool.poetry.extras] FiniteElement = ["nutils"] @@ -61,11 +63,70 @@ pytest-benchmark = "^4.0.0" cython = "^0.29.34" sphinx = "^6.1.3" bump-my-version = "^0.17.3" +ruff = "^0.2.1" +mypy = "^1.8.0" +rhino3dm = "^8.0.1" +opencv-python = "^4.9.0.80" +h5py = "^3.10.0" +tqdm = "^4.66.2" [build-system] requires = ["poetry-core", "cython", "numpy", "wheel", "setuptools"] build-backend = "poetry.core.masonry.api" +[tool.ruff] +line-length = 110 +exclude = [ + "splipy/utils/bisect.py", # TODO: Py310 - remove this file +] + +# Please select from the menu: https://docs.astral.sh/ruff/rules +[tool.ruff.lint] +select = [ + "F", # Pyflakes rules + "W", # PyCodeStyle warnings + "E", # PyCodeStyle errors + "I", # Sort imports properly + "UP", # Warn if certain things can changed due to newer Python versions + "C4", # Catch incorrect use of comprehensions, dict, list, etc + "FA", # Enforce from __future__ import annotations + "ISC", # Good use of string concatenation + "ICN", # Use common import conventions + "RET", # Good return practices + "SIM", # Common simplification rules + "TID", # Some good import practices + "TCH", # Enforce importing certain types in a TYPE_CHECKING block + "PTH", # Use pathlib instead of os.path + "TD", # Be diligent with TODO comments + "NPY", # Some numpy-specific things +] +ignore = [ + "E741", # Ambiguous variable name + "UP007", # TODO: Py310 - enable this when dropping support for Py39 + "SIM115", # Complains if we use __enter__ inside an __enter__ method + "ISC001", # Conflicts with rust formatting + "TD003", # Issue links for each todo comment +] + +[tool.mypy] +plugins = ["numpy.typing.mypy_plugin"] +files = ["splipy/**/*.py"] +disallow_untyped_defs = true +disallow_any_unimported = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs" + +[[tool.mypy.overrides]] +module = "splipy.io.grdecl" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "splipy.utils.bisect" +ignore_errors = true + [tool.bumpversion] current_version = "1.8.2" allow_dirty = false diff --git a/splipy/__init__.py b/splipy/__init__.py index 86a3913..5dd51af 100644 --- a/splipy/__init__.py +++ b/splipy/__init__.py @@ -1,9 +1,18 @@ from .basis import BSplineBasis -from .splineobject import SplineObject from .curve import Curve +from .splinemodel import SplineModel +from .splineobject import SplineObject from .surface import Surface -from .volume import Volume from .trimmedsurface import TrimmedSurface -from .splinemodel import SplineModel +from .volume import Volume -__version__ = '1.8.2' +__version__ = "1.8.2" +__all__ = [ + "BSplineBasis", + "SplineObject", + "Curve", + "Surface", + "Volume", + "TrimmedSurface", + "SplineModel", +] diff --git a/splipy/basis.py b/splipy/basis.py index 81a5b37..1a93044 100644 --- a/splipy/basis.py +++ b/splipy/basis.py @@ -1,15 +1,22 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from bisect import bisect_right, bisect_left import copy +from bisect import bisect_left, bisect_right +from typing import TYPE_CHECKING, Literal, Optional, Union, overload import numpy as np from scipy.sparse import csr_matrix -from .utils import ensure_listlike from . import basis_eval, state +from .utils import ensure_listlike, ensure_scalars -__all__ = ['BSplineBasis'] +if TYPE_CHECKING: + from collections.abc import MutableSequence + + from .types import FArray, Scalar, ScalarOrScalars, Scalars + + +__all__ = ["BSplineBasis"] class BSplineBasis: @@ -20,12 +27,13 @@ class BSplineBasis: BSplineBasis objects support basic arithmetic operators, which are interpreted as acting on the parametric domain. """ - knots = [0, 0, 1, 1] - order = 2 - periodic = -1 - def __init__(self, order=2, knots=None, periodic=-1): - """ Construct a B-Spline basis with a given order and knot vector. + knots: FArray + order: int + periodic: int + + def __init__(self, order: int = 2, knots: Optional[Scalars] = None, periodic: int = -1) -> None: + """Construct a B-Spline basis with a given order and knot vector. :param int order: Spline order, i.e. one greater than the polynomial degree. :param [float] knots: Knot vector of non-decreasing components. @@ -36,49 +44,56 @@ def __init__(self, order=2, knots=None, periodic=-1): """ periodic = max(periodic, -1) + if knots is None: - knots = [0] * order + [1] * order - for i in range(periodic+1): - knots[ i] = -1 - knots[-i-1] = 2 + self.knots = np.array([0] * order + [1] * order, dtype=float) + for i in range(periodic + 1): + self.knots[i] = -1 + self.knots[-i - 1] = 2 + else: + self.knots = np.array(knots, dtype=float) - self.knots = np.array(knots) - self.knots = self.knots.astype(float) self.order = order self.periodic = periodic - # error test input - p = order - k = periodic - n = len(knots) - if p < 1: - raise ValueError('invalid spline order') - if n < 2*p: - raise ValueError('knot vector has too few elements') - if periodic >= 0: - for i in range(p + k - 1): - if abs((knots[i + 1] - knots[i]) - (knots[-p - k + i ] - knots[-p - k - 1 + i])) > state.knot_tolerance: - raise ValueError('periodic knot vector is mis-matching at the start/end') - for i in range(len(knots) - 1): - if knots[i + 1] - knots[i] < -state.knot_tolerance: - raise ValueError('knot vector needs to be non-decreasing') - - def num_functions(self): - """ Returns the number of basis functions in the basis. + self._error_check() + + def _error_check(self) -> None: + if self.order < 1: + raise ValueError("invalid spline order") + + if len(self.knots) < 2 * self.order: + raise ValueError("knot vector has too few elements") + + if self.periodic >= 0: + endpt = -self.order - self.periodic + for i in range(self.order + self.periodic - 1): + diff = np.abs( + (self.knots[i + 1] - self.knots[i]) - (self.knots[endpt + i] - self.knots[endpt - 1 + i]) + ) + if diff > state.knot_tolerance: + raise ValueError("periodic knot vector is mis-matching at the start/end") + + for i in range(len(self.knots) - 1): + if self.knots[i + 1] - self.knots[i] < -state.knot_tolerance: + raise ValueError("knot vector needs to be non-decreasing") + + def num_functions(self) -> int: + """Return the number of basis functions in the basis. .. warning:: This is different from :func:`splipy.BSplineBasis.__len__`.""" return len(self.knots) - self.order - (self.periodic + 1) - def start(self): + def start(self) -> float: """Start point of parametric domain. For open knot vectors, this is the first knot. :return: Knot number *p*, where *p* is the spline order :rtype: float """ - return self.knots[self.order - 1] + return self.knots[self.order - 1] # type: ignore[no-any-return] - def end(self): + def end(self) -> float: """End point of parametric domain. For open knot vectors, this is the last knot. @@ -86,28 +101,40 @@ def end(self): the number of knots :rtype: Float """ - return self.knots[-self.order] + return self.knots[-self.order] # type: ignore[no-any-return] + + @overload + def greville(self) -> FArray: + ... + + @overload + def greville(self, index: int) -> float: + ... - def greville(self, index=None): - """ Fetch greville points, also known as knot averages: + def greville(self, index=None): # type: ignore[no-untyped-def] + """Fetch greville points, also known as knot averages: .. math:: \\sum_{j=i+1}^{i+p-1} \\frac{t_j}{p-1} :return: One, or all of the Greville points :rtype: [float] (if *index* is ``None``) or float """ - result = [] p = self.order n = self.num_functions() if index is None: - for i in range(n): - result.append(float(np.sum(self.knots[i + 1:i + p])) / (p - 1)) - else: - result = float(np.sum(self.knots[index + 1:index + p])) / (p - 1) - return result - - def evaluate(self, t, d=0, from_right=True, sparse=False): - """ Evaluate all basis functions in a given set of points. + return np.fromiter( + (np.sum(self.knots[i + 1 : i + p]) / (p - 1) for i in range(n)), + dtype=float, + ) + return np.sum(self.knots[index + 1 : index + p]) / (p - 1) + + def evaluate_sparse( + self, + t: ScalarOrScalars, + d: int = 0, + from_right: bool = True, + ) -> csr_matrix: + """Evaluate all basis functions in a given set of points. :param t: The parametric coordinate(s) in which to evaluate :type t: float or [float] @@ -117,26 +144,120 @@ def evaluate(self, t, d=0, from_right=True, sparse=False): :param bool sparse: True if computed matrix should be returned as sparse :return: A matrix *N[i,j]* of all basis functions *j* evaluated in all points *i* - :rtype: numpy.array + :rtype: csr_matrix """ # for single-value input, wrap it into a list so it don't crash on the loop below - t = ensure_listlike(t) + t = ensure_scalars(t) t = np.array(t, dtype=float) basis_eval.snap(self.knots, t, state.knot_tolerance) - if self.order <= d: # requesting more derivatives than polymoial degree: return all zeros - return np.zeros((len(t), self.num_functions())) + if self.order <= d: # requesting more derivatives than polymoial degree: return all zeros + return csr_matrix((len(t), self.num_functions())) + + data, size = basis_eval.evaluate( + self.knots, + self.order, + t, + self.periodic, + state.knot_tolerance, + d, + from_right, + ) + return csr_matrix(data, size) + + def evaluate_dense( + self, + t: ScalarOrScalars, + d: int = 0, + from_right: bool = True, + ) -> FArray: + """Evaluate all basis functions in a given set of points. - (data, size) = basis_eval.evaluate(self.knots, self.order, t, self.periodic, state.knot_tolerance, d, from_right) + :param t: The parametric coordinate(s) in which to evaluate + :type t: float or [float] + :param int d: Number of derivatives to compute + :param bool from_right: True if evaluation should be done in the limit + from above + :param bool sparse: True if computed matrix should be returned as sparse + :return: A matrix *N[i,j]* of all basis functions *j* evaluated in all + points *i* + :rtype: numpy.ndarray + """ + return self.evaluate_sparse(t, d, from_right).toarray() + + @overload + def evaluate( + self, + t: ScalarOrScalars, + *, + sparse: Literal[True], + d: int = 0, + from_right: bool = True, + ) -> csr_matrix: + ... + + @overload + def evaluate( + self, + t: ScalarOrScalars, + *, + d: int = 0, + from_right: bool = True, + ) -> FArray: + ... + + @overload + def evaluate( + self, + t: ScalarOrScalars, + *, + sparse: Literal[False], + d: int = 0, + from_right: bool = True, + ) -> FArray: + ... + + def evaluate(self, t, d=0, from_right=True, sparse=False): # type: ignore[no-untyped-def] + """Evaluate all basis functions in a given set of points. - N = csr_matrix(data, size) - if not sparse: - N = N.toarray() - return N + :param t: The parametric coordinate(s) in which to evaluate + :type t: float or [float] + :param int d: Number of derivatives to compute + :param bool from_right: True if evaluation should be done in the limit + from above + :param bool sparse: True if computed matrix should be returned as sparse + :return: A matrix *N[i,j]* of all basis functions *j* evaluated in all + points *i* + :rtype: numpy.array + """ + return self.evaluate_sparse(t, d, from_right) if sparse else self.evaluate_dense(t, d, from_right) + + @overload + def evaluate_old( + self, + t: ScalarOrScalars, + *, + sparse: Literal[True], + d: int = 0, + from_right: bool = True, + ) -> csr_matrix: + ... + + @overload + def evaluate_old( + self, + t: ScalarOrScalars, + *, + sparse: Literal[False] = False, + d: int = 0, + from_right: bool = True, + ) -> FArray: + ... + + def evaluate_old(self, t, d=0, from_right=True, sparse=False): # type: ignore[no-untyped-def] + """Evaluate all basis functions in a given set of points. - def evaluate_old(self, t, d=0, from_right=True, sparse=False): - """ Evaluate all basis functions in a given set of points. :param t: The parametric coordinate(s) in which to evaluate :type t: float or [float] :param int d: Number of derivatives to compute @@ -153,13 +274,13 @@ def evaluate_old(self, t, d=0, from_right=True, sparse=False): p = self.order # knot vector order n_all = len(self.knots) - p # number of basis functions (without periodicity) - n = len(self.knots) - p - (self.periodic+1) # number of basis functions (with periodicity) + n = len(self.knots) - p - (self.periodic + 1) # number of basis functions (with periodicity) m = len(t) - data = np.zeros(m*p) - indices = np.zeros(m*p, dtype='int32') - indptr = np.array(range(0,m*p+1,p), dtype='int32') - if p <= d: # requesting more derivatives than polymoial degree: return all zeros - return np.zeros((m,n)) + data = np.zeros(m * p) + indices = np.zeros(m * p, dtype="int32") + indptr = np.array(range(0, m * p + 1, p), dtype="int32") + if p <= d: # requesting more derivatives than polymoial degree: return all zeros + return np.zeros((m, n)) if self.periodic >= 0: t = copy.deepcopy(t) # Wrap periodic evaluation into domain @@ -177,92 +298,90 @@ def evaluate_old(self, t, d=0, from_right=True, sparse=False): continue # mu = index of last non-zero basis function - if right: - mu = bisect_right(self.knots, evalT) - else: - mu = bisect_left(self.knots, evalT) + mu = (bisect_right if right else bisect_left)(self.knots, evalT) mu = min(mu, n_all) M = np.zeros(p) # temp storage to keep all the function evaluations M[-1] = 1 # the last entry is a dummy-zero which is never used - for q in range(1, p-d): + for q in range(1, p - d): for j in range(p - q - 1, p): k = mu - p + j # 'i'-index in global knot vector (ref Hughes book pg.21) - if j != p-q-1: + if j != p - q - 1: M[j] = M[j] * float(evalT - self.knots[k]) / (self.knots[k + q] - self.knots[k]) - if j != p-1: - M[j] = M[j] + M[j + 1] * float(self.knots[k + q + 1] - evalT) / (self.knots[k + q + 1] - self.knots[k + 1]) - + if j != p - 1: + M[j] = M[j] + M[j + 1] * float(self.knots[k + q + 1] - evalT) / ( + self.knots[k + q + 1] - self.knots[k + 1] + ) - for q in range(p-d, p): + for q in range(p - d, p): for j in range(p - q - 1, p): k = mu - p + j # 'i'-index in global knot vector (ref Hughes book pg.21) - if j != p-q-1: + if j != p - q - 1: M[j] = M[j] * float(q) / (self.knots[k + q] - self.knots[k]) - if j != p-1: + if j != p - 1: M[j] = M[j] - M[j + 1] * float(q) / (self.knots[k + q + 1] - self.knots[k + 1]) + data[i * p : (i + 1) * p] = M + indices[i * p : (i + 1) * p] = np.arange(mu - p, mu) % n - data[i*p:(i+1)*p] = M - indices[i*p:(i+1)*p] = np.arange(mu-p, mu) % n - - N = csr_matrix((data, indices, indptr), (m,n)) - if not sparse: - N = N.toarray() - return N + N = csr_matrix((data, indices, indptr), (m, n)) + return N if sparse else N.toarray() - def integrate(self, t0, t1): - """ Integrate all basis functions over a given domain + def integrate(self, t0: Scalar, t1: Scalar) -> FArray: + """Integrate all basis functions over a given domain :param float t0: The parametric starting point :param float t1: The parametric end point :return: The integration of all functions over the input domain - :rtype: list + :rtype: numpy.ndarray """ - if self.periodic > -1 and (t0self.end()): - raise NotImplemented('Periodic functions integrated across sem') + if self.periodic > -1 and (t0 < self.start() or t1 > self.end()): + raise NotImplementedError("Periodic functions integrated across sem") t0 = max(t0, self.start()) - t1 = min(t1, self.end() ) - p = self.order + t1 = min(t1, self.end()) + p = self.order knot = [self.knots[0]] + list(self.knots) + [self.knots[-1]] integration_basis = BSplineBasis(p + 1, knot) N0 = np.array(integration_basis.evaluate(t0)).flatten() N1 = np.array(integration_basis.evaluate(t1)).flatten() - N = [(knot[i+p]-knot[i])*1.0/p * np.sum(N1[i:]-N0[i:]) for i in range(N0.size)] - N = N[1:] + N = [(knot[i + p] - knot[i]) * 1.0 / p * np.sum(N1[i:] - N0[i:]) for i in range(N0.size)] + N = N[1:] # collapse periodic functions onto themselves if self.periodic > -1: for j in range(self.periodic + 1): N[j] += N[-self.periodic - 1 + j] - N = N[:-self.periodic-1] + N = N[: -self.periodic - 1] - return N + return np.array(N, dtype=float) - def normalize(self): + def normalize(self) -> None: """Set the parametric domain to be (0,1).""" self -= self.start() # set start-point to 0 self /= self.end() # set end-point to 1 - def reparam(self, start=0, end=1): - """ Set the parametric domain to be (start, end) + def reparam(self, start: Scalar = 0, end: Scalar = 1) -> None: + """Set the parametric domain to be (start, end) :raises ValueError: If *end* ≤ *start*""" if end <= start: - raise ValueError('end must be larger than start') + raise ValueError("end must be larger than start") self.normalize() - self *= (end - start) + self *= end - start self += start - def reverse(self): + def reverse(self) -> None: """Reverse parametric domain, keeping start/end values unchanged.""" - a = float(self.start()) - b = float(self.end()) + a = self.start() + b = self.end() self.knots = (self.knots[::-1] - a) / (b - a) * (a - b) + b - def continuity(self, knot): + # NOTE: We are lying here - this function can return a float. + # The only possible float return value is infinity, and we do this + # so that it can be used in min(...). + def continuity(self, knot: Scalar) -> int: """Get the continuity of the basis functions at a given point. :return: *p*--*m*--1 at a knot with multiplicity *m*, or ``inf`` @@ -273,7 +392,7 @@ def continuity(self, knot): if knot < self.start() or knot > self.end(): knot = (knot - self.start()) % (self.end() - self.start()) + self.start() elif knot < self.start() or self.end() < knot: - raise ValueError('out of range') + raise ValueError("out of range") # First knot that is larger than the right tolerance point hi = bisect_left(self.knots, knot + state.knot_tolerance) @@ -282,10 +401,10 @@ def continuity(self, knot): lo = bisect_left(self.knots, knot - state.knot_tolerance) if hi == lo: - return np.inf + return np.inf # type: ignore[return-value] return self.order - (hi - lo) - 1 - def make_periodic(self, continuity): + def make_periodic(self, continuity: int) -> BSplineBasis: """Create a periodic basis with a given continuity.""" deg = self.order - 1 new_knots = self.knots[deg:-deg] @@ -294,13 +413,13 @@ def make_periodic(self, continuity): n_reps = deg - continuity - 1 n_copy = deg - n_reps - head = new_knots[-n_copy-1:-1] - diff - tail = new_knots[1:n_copy+1] + diff + head = new_knots[-n_copy - 1 : -1] - diff + tail = new_knots[1 : n_copy + 1] + diff new_knots = np.hstack((head, [self.start()] * n_reps, new_knots, [self.end()] * n_reps, tail)) return BSplineBasis(self.order, new_knots, continuity) - def knot_spans(self, include_ghost_knots=False): + def knot_spans(self, include_ghost_knots: bool = False) -> FArray: """Return the set of unique knots in the knot vector. :param bool include_ghost_knots: if knots outside start/end are to be @@ -308,79 +427,87 @@ def knot_spans(self, include_ghost_knots=False): :return: List of unique knots :rtype: [float]""" p = self.order + if include_ghost_knots: result = [self.knots[0]] for k in self.knots: if abs(k - result[-1]) > state.knot_tolerance: result.append(k) else: - result = [self.knots[p-1]] - for k in self.knots[p-1:-p+1]: + result = [self.knots[p - 1]] + for k in self.knots[p - 1 : -p + 1]: if abs(k - result[-1]) > state.knot_tolerance: result.append(k) - return result - def raise_order(self, amount): + return np.array(result, dtype=float) + + def raise_order(self, amount: int) -> BSplineBasis: """Create a knot vector with higher order. The continuity at the knots are kept unchanged by increasing their multiplicities. - :return: New knot vector - :rtype: [float] + :return: New basis + :rtype: BSplineBasis :raises TypeError: If `amount` is not an int :raises ValueError: If `amount` is negative """ - if type(amount) is not int: - raise TypeError('amount needs to be a non-negative integer') + if not isinstance(amount, int): + raise TypeError("amount needs to be a non-negative integer") if amount < 0: - raise ValueError('amount needs to be a non-negative integer') + raise ValueError("amount needs to be a non-negative integer") if amount == 0: return self.clone() - knot_spans = list(self.knot_spans(True)) # list of unique knots + + knot_spans = list(self.knot_spans(include_ghost_knots=True)) # list of unique knots + # For every degree we raise, we need to increase the multiplicity by one knots = list(self.knots) + knot_spans * amount + # make it a proper knot vector by ensuring that it is non-decreasing knots.sort() + if self.periodic > -1: # remove excessive ghost knots which appear at both ends of the knot vector - n0 = bisect_left(knot_spans, self.start()) - n1 = len(knot_spans) - bisect_left(knot_spans, self.end()) - 1 - knots = knots[n0*amount : -n1*amount] + n0 = bisect_left(knot_spans, self.start()) + n1 = len(knot_spans) - bisect_left(knot_spans, self.end()) - 1 + knots = knots[n0 * amount : -n1 * amount] return BSplineBasis(self.order + amount, knots, self.periodic) - def lower_order(self, amount): + def lower_order(self, amount: int) -> BSplineBasis: """Create a knot vector with lower order. The continuity at the knots are kept unchanged by decreasing their multiplicities. - :return: New knot vector - :rtype: [float] + :return: New basis + :rtype: BSplineBasis :raises TypeError: If `amount` is not an int :raises ValueError: If `amount` is negative """ - if type(amount) is not int: - raise TypeError('amount needs to be a non-negative integer') + if not isinstance(amount, int): + raise TypeError("amount needs to be a non-negative integer") if amount < 0: - raise ValueError('amount needs to be a non-negative integer') + raise ValueError("amount needs to be a non-negative integer") if self.order - amount < 2: - raise ValueError('cannot lower order to less than linears') + raise ValueError("cannot lower order to less than linears") p = self.order - amount - knots = [ [k] * max(p-1-self.continuity(k), 1) for k in self.knot_spans(True)] - knots = [ k for sublist in knots for k in sublist] + knots_nested = [ + [k] * max(p - 1 - self.continuity(k), 1) for k in self.knot_spans(include_ghost_knots=True) + ] + knots = [k for sublist in knots_nested for k in sublist] if self.periodic > -1: # remove excessive ghost knots which appear at both ends of the knot vector - n0 = bisect_left(knot_spans, self.start()) - n1 = len(knot_spans) - bisect_left(knot_spans, self.end()) - 1 - knots = knots[n0*amount : -n1*amount] + n0 = bisect_left(knots, self.start()) + n1 = len(knots) - bisect_left(knots, self.end()) - 1 + knots = knots[n0 * amount : -n1 * amount] return BSplineBasis(p, knots, self.periodic) - def insert_knot(self, new_knot): + def insert_knot(self, new_knot: Scalar) -> FArray: """Inserts a knot in the knot vector. The return value is a sparse matrix *C* (actually, a dense matrix with @@ -396,7 +523,7 @@ def insert_knot(self, new_knot): if new_knot < self.start() or new_knot > self.end(): new_knot = (new_knot - self.start()) % (self.end() - self.start()) + self.start() elif new_knot < self.start() or self.end() < new_knot: - raise ValueError('new_knot out of range') + raise ValueError("new_knot out of range") # mu is the index of last non-zero (old) basis function mu = bisect_right(self.knots, new_knot) n = self.num_functions() @@ -409,13 +536,13 @@ def insert_knot(self, new_knot): if self.knots[i + p - 1] <= new_knot and new_knot <= self.knots[i + p]: C[i % (n + 1), i % n] = 1 else: - C[i % (n + 1), i % n] = (new_knot - self.knots[i]) / ( - self.knots[i + p - 1] - self.knots[i]) + C[i % (n + 1), i % n] = (new_knot - self.knots[i]) / (self.knots[i + p - 1] - self.knots[i]) if self.knots[i] <= new_knot and new_knot <= self.knots[i + 1]: C[(i + 1) % (n + 1), i % n] = 1 else: C[(i + 1) % (n + 1), i % n] = (self.knots[i + p] - new_knot) / ( - self.knots[i + p] - self.knots[i + 1]) + self.knots[i + p] - self.knots[i + 1] + ) for i in range(mu, n + 1): C[i % (n + 1), (i - 1) % n] = 1 @@ -423,107 +550,110 @@ def insert_knot(self, new_knot): # make sure that it is correct periodic after knot insertion if self.periodic > -1: - m = len(self.knots) - r = self.periodic - if mu <= p+r: # need to fix ghost knots on right side + m = len(self.knots) + r = self.periodic + if mu <= p + r: # need to fix ghost knots on right side k0 = self.knots[0] - k1 = self.knots[-p-r-1] - for i in range(p+r+1): - self.knots[m-p-r-1+i] = k1 + (self.knots[i]-k0) - elif mu >= m-p-r-1: # need to fix ghost knots on left side - k0 = self.knots[p+r] + k1 = self.knots[-p - r - 1] + for i in range(p + r + 1): + self.knots[m - p - r - 1 + i] = k1 + (self.knots[i] - k0) + elif mu >= m - p - r - 1: # need to fix ghost knots on left side + k0 = self.knots[p + r] k1 = self.knots[-1] - for i in range(p+r+1): - self.knots[i] = k0 - (k1-self.knots[m-p-r-1+i]) + for i in range(p + r + 1): + self.knots[i] = k0 - (k1 - self.knots[m - p - r - 1 + i]) return C - def roll(self, new_start): - """rotate a periodic knot vector by setting a new starting index. + def roll(self, new_start: Scalar) -> None: + """Rotate a periodic knot vector by setting a new starting index. :param int new_start: The index of to the new first knot """ if self.periodic < 0: - raise RuntimeError("roll only applicable for periodic knot vectors") + raise RuntimeError("roll only applicable for periodic knot vectors") p = self.order k = self.periodic n = len(self.knots) t1 = self.knots[0] - self.knots[-p - k - 1] - left = slice(new_start, n-p-k-1, None) + left = slice(new_start, n - p - k - 1, None) len_left = left.stop - left.start - right = slice(0, n-len_left, None) - (self.knots[:len_left], self.knots[len_left:]) = (self.knots[left], self.knots[right] - t1) + right = slice(0, n - len_left, None) + + # NOTE: This has to be done 'at once' - the slices may overlap + self.knots[:len_left], self.knots[len_left:] = self.knots[left], self.knots[right] - t1 - def matches(self, bspline, reverse=False): - """ Checks if this basis equals another basis, when disregarding + def matches(self, bspline: BSplineBasis, reverse: bool = False) -> bool: + """Checks if this basis equals another basis, when disregarding scaling and translation of the knots vector. I.e. will this basis and *bspline* yield the same spline object if paired with identical - controlpoints """ + controlpoints""" if self.order != bspline.order or self.periodic != bspline.periodic: return False - dt = self.knots[-1] - self.knots[0] + dt = self.knots[-1] - self.knots[0] dt2 = bspline.knots[-1] - bspline.knots[0] if reverse: - return np.allclose( (self.knots[-1]-self.knots[::-1]) / dt, - (bspline.knots-bspline.knots[0]) / dt2, - atol=state.knot_tolerance) - else: - return np.allclose( (self.knots-self.knots[0]) / dt, - (bspline.knots-bspline.knots[0]) / dt2, - atol=state.knot_tolerance) - - def snap(self, t): - """ Snap evaluation points to knots if they are sufficiently close - as given in by state.state.knot_tolerance. This will modify the input vector t - - :param t: evaluation points - :type  t: [float] + return np.allclose( + (self.knots[-1] - self.knots[::-1]) / dt, + (bspline.knots - bspline.knots[0]) / dt2, + atol=state.knot_tolerance, + ) + return np.allclose( + (self.knots - self.knots[0]) / dt, + (bspline.knots - bspline.knots[0]) / dt2, + atol=state.knot_tolerance, + ) + + def snap(self, t: Union[MutableSequence[float], FArray]) -> None: + """Snap evaluation points to knots if they are sufficiently close + as given in by state.state.knot_tolerance. This will modify the input vector t. + + :param t: evaluation points + :type t: [float] :return: none """ n = len(self.knots) for j in range(len(t)): i = bisect_left(self.knots, t[j]) - if i < n and abs(self.knots[i]-t[j]) < state.knot_tolerance: + if i < n and abs(self.knots[i] - t[j]) < state.knot_tolerance: t[j] = self.knots[i] - elif i > 0 and abs(self.knots[i-1]-t[j]) < state.knot_tolerance: - t[j] = self.knots[i-1] - + elif i > 0 and abs(self.knots[i - 1] - t[j]) < state.knot_tolerance: + t[j] = self.knots[i - 1] - def clone(self): - """Clone the object.""" + def clone(self) -> BSplineBasis: + """Clone the basis.""" return copy.deepcopy(self) __call__ = evaluate - def __len__(self): + def __len__(self) -> int: """Returns the number of knots in this basis.""" return len(self.knots) - def __getitem__(self, i): + def __getitem__(self, i: int) -> float: """Returns the knot at a given index.""" - return self.knots[i] + return self.knots[i] # type: ignore[no-any-return] - def __iadd__(self, a): + def __iadd__(self, a: Scalar) -> BSplineBasis: self.knots += a return self - def __isub__(self, a): + def __isub__(self, a: Scalar) -> BSplineBasis: self.knots -= a return self - def __imul__(self, a): + def __imul__(self, a: Scalar) -> BSplineBasis: self.knots *= a return self - def __itruediv__(self, a): + def __itruediv__(self, a: Scalar) -> BSplineBasis: self.knots /= a return self __ifloordiv__ = __itruediv__ # integer division (should not distinguish) - __idiv__ = __itruediv__ # python2 compatibility - def __repr__(self): - result = 'p=' + str(self.order) + ', ' + str(self.knots) + def __repr__(self) -> str: + result = "p=" + str(self.order) + ", " + str(self.knots) if self.periodic > -1: - result += ', C' + str(self.periodic) + '-periodic' + result += ", C" + str(self.periodic) + "-periodic" return result diff --git a/splipy/basis_eval.pyi b/splipy/basis_eval.pyi new file mode 100644 index 0000000..4744638 --- /dev/null +++ b/splipy/basis_eval.pyi @@ -0,0 +1,20 @@ +from numpy import float64, int_ +from numpy.typing import NDArray + +def snap(knots: NDArray[float64], eval_pts: NDArray[float64], tolerance: float) -> None: ... +def evaluate( + knots: NDArray[float64], + order: int, + eval_pts: NDArray[float64], + periodic: int, + tolerance: float, + d: int, + from_right: bool = True, +) -> tuple[ + tuple[ + NDArray[float64], + NDArray[int_], + NDArray[int_], + ], + tuple[int, int], +]: ... diff --git a/splipy/curve.py b/splipy/curve.py index 74eb36a..45961cf 100644 --- a/splipy/curve.py +++ b/splipy/curve.py @@ -1,16 +1,21 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from itertools import chain from bisect import bisect_left, bisect_right +from typing import TYPE_CHECKING, Any, Callable, Optional, Sequence, Union, cast, overload import numpy as np import scipy.sparse.linalg as splinalg +from typing_extensions import Self from .basis import BSplineBasis from .splineobject import SplineObject -from .utils import ensure_listlike, is_singleton +from .utils import ensure_listlike, ensure_scalars, is_singleton -__all__ = ['Curve'] +if TYPE_CHECKING: + from .types import Direction, FArray, Scalar, ScalarOrScalars, Scalars + + +__all__ = ["Curve"] class Curve(SplineObject): @@ -20,8 +25,14 @@ class Curve(SplineObject): _intended_pardim = 1 - def __init__(self, basis=None, controlpoints=None, rational=False, **kwargs): - """ Construct a curve with the given basis and control points. + def __init__( + self, + basis: Optional[BSplineBasis] = None, + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> None: + """Construct a curve with the given basis and control points. The default is to create a linear one-element mapping from (0,1) to the unit interval. @@ -32,10 +43,10 @@ def __init__(self, basis=None, controlpoints=None, rational=False, **kwargs): control points are interpreted as pre-multiplied with the weight, which is the last coordinate) """ - super(Curve, self).__init__([basis], controlpoints, rational, **kwargs) + super().__init__([basis], controlpoints, rational=rational, raw=raw) - def evaluate(self, *params): - """ Evaluate the object at given parametric values. + def evaluate(self, params: ScalarOrScalars, tensor: bool = True) -> FArray: # type: ignore[override] + """Evaluate the object at given parametric values. This function returns an *n1* × *n2* × ... × *dim* array, where *ni* is the number of evaluation points in direction *i*, and *dim* is the @@ -49,14 +60,15 @@ def evaluate(self, *params): :return: Geometry coordinates :rtype: numpy.array """ - squeeze = is_singleton(params[0]) - params = [ensure_listlike(p) for p in params] - self._validate_domain(*params) + squeeze = is_singleton(params) + params_list = ensure_scalars(params) + + self._validate_domain(params_list) # Evaluate the derivatives of the corresponding bases at the corresponding points # and build the result array - N = self.bases[0].evaluate(params[0], sparse=True) + N = self.bases[0].evaluate(params_list, sparse=True) result = N @ self.controlpoints # For rational objects, we divide out the weights, which are stored in the @@ -72,8 +84,14 @@ def evaluate(self, *params): return result - def derivative(self, t, d=1, above=True, tensor=True): - """ Evaluate the derivative of the curve at the given parametric values. + def derivative( # type: ignore[override] + self, + t: ScalarOrScalars, + d: Union[int, Sequence[int]] = 1, + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> FArray: + """Evaluate the derivative of the curve at the given parametric values. This function returns an *n* × *dim* array, where *n* is the number of evaluation points, and *dim* is the physical dimension of the curve. @@ -89,43 +107,47 @@ def derivative(self, t, d=1, above=True, tensor=True): :return: Derivative array :rtype: numpy.array """ - if not is_singleton(d): - d = d[0] + d = ensure_listlike(d)[0] + above = ensure_listlike(above)[0] if not self.rational or d < 2 or d > 3: - return super(Curve, self).derivative(t, d=d, above=above, tensor=tensor) + return super().derivative(t, d=d, above=above, tensor=tensor) - t = ensure_listlike(t) + t = ensure_scalars(t) result = np.zeros((len(t), self.dimension)) - d2 = np.array(self.bases[0].evaluate(t, 2, above) @ self.controlpoints) - d1 = np.array(self.bases[0].evaluate(t, 1, above) @ self.controlpoints) - d0 = np.array(self.bases[0].evaluate(t) @ self.controlpoints) - W = d0[:, -1] # W(t) + d2 = np.array(self.bases[0].evaluate_dense(t, d=2, from_right=above) @ self.controlpoints) + d1 = np.array(self.bases[0].evaluate_dense(t, d=1, from_right=above) @ self.controlpoints) + d0 = np.array(self.bases[0].evaluate_dense(t) @ self.controlpoints) + W = d0[:, -1] # W(t) W1 = d1[:, -1] # W'(t) W2 = d2[:, -1] # W''(t) if d == 2: for i in range(self.dimension): - result[:, i] = (d2[:, i] * W * W - 2 * W1 * - (d1[:, i] * W - d0[:, i] * W1) - d0[:, i] * W2 * W) / W / W / W + result[:, i] = ( + (d2[:, i] * W * W - 2 * W1 * (d1[:, i] * W - d0[:, i] * W1) - d0[:, i] * W2 * W) + / W + / W + / W + ) if d == 3: - d3 = np.array(self.bases[0].evaluate(t, 3, above) @ self.controlpoints) - W3 = d3[:,-1] # W'''(t) - W6 = W*W*W*W*W*W # W^6 + d3 = np.array(self.bases[0].evaluate(t, d=3, from_right=above) @ self.controlpoints) + W3 = d3[:, -1] # W'''(t) + # W6 = W*W*W*W*W*W # W^6 for i in range(self.dimension): - H = d1[:,i]*W - d0[:,i]*W1 - H1 = d2[:,i]*W - d0[:,i]*W2 - H2 = d3[:,i]*W + d2[:,i]*W1 - d1[:,i]*W2 - d0[:,i]*W3 - G = H1*W - 2*H*W1 - G1 = H2*W - 2*H*W2 - H1*W1 - result[:, i] = (G1*W - 3*G*W1) /W/W/W/W + H = d1[:, i] * W - d0[:, i] * W1 + H1 = d2[:, i] * W - d0[:, i] * W2 + H2 = d3[:, i] * W + d2[:, i] * W1 - d1[:, i] * W2 - d0[:, i] * W3 + G = H1 * W - 2 * H * W1 + G1 = H2 * W - 2 * H * W2 - H1 * W1 + result[:, i] = (G1 * W - 3 * G * W1) / W / W / W / W if result.shape[0] == 1: # in case of single value input t, return vector instead of matrix result = np.array(result[0, :]).reshape(self.dimension) return result - def binormal(self, t, above=True): - """ Evaluate the normalized binormal of the curve at the given parametric value(s). + def binormal(self, t: ScalarOrScalars, above: bool = True) -> FArray: + """Evaluate the normalized binormal of the curve at the given parametric value(s). This function returns an *n* × 3 array, where *n* is the number of evaluation points. @@ -141,27 +163,24 @@ def binormal(self, t, above=True): """ # error test input if self.dimension != 3: - raise ValueError('Binormals require dimension = 3') + raise ValueError("Binormals require dimension = 3") # compute derivative - dx = self.derivative(t, d=1, above=above) - ddx = self.derivative(t, d=2, above=above) + dx = self.derivative(t, d=1, above=above) + ddx = self.derivative(t, d=2, above=above) # in case of vanishing acceleration, colinear velocity and acceleration, # such as linear curves we guess an appropriate binbormal (multiple choice available) if len(dx.shape) == 1: if np.allclose(ddx, 0): - if np.allclose(dx[:2], 0): # dx = [0,0,1] - ddx = np.array([1,0,0]) - else: - ddx = np.array([0,0,1]) + ddx = np.array([1, 0, 0]) if np.allclose(dx[:2], 0) else np.array([0, 0, 1]) else: for i in range(ddx.shape[0]): - if np.allclose(ddx[i,:], 0): - if np.allclose(dx[i,:2], 0): # dx = [0,0,1] - ddx[i,:] = np.array([1,0,0]) + if np.allclose(ddx[i, :], 0): + if np.allclose(dx[i, :2], 0): # dx = [0,0,1] + ddx[i, :] = np.array([1, 0, 0]) else: - ddx[i,:] = np.array([0,0,1]) + ddx[i, :] = np.array([0, 0, 1]) result = np.cross(dx, ddx) @@ -170,13 +189,13 @@ def binormal(self, t, above=True): return result / np.linalg.norm(result) # normalize - magnitude = np.linalg.norm(result, axis=1) - magnitude = magnitude.reshape(-1,1) + magnitude: FArray = np.linalg.norm(result, axis=1) + magnitude = magnitude.reshape(-1, 1) return result / magnitude - def normal(self, t, above=True): - """ Evaluate the normal of the curve at the given parametric value(s). + def normal(self, t: ScalarOrScalars, above: bool = True) -> FArray: + """Evaluate the normal of the curve at the given parametric value(s). This function returns an *n* × 3 array, where *n* is the number of evaluation points. @@ -192,16 +211,24 @@ def normal(self, t, above=True): """ # error test input if self.dimension != 3: - raise RuntimeError('Normals require dimension = 3') + raise RuntimeError("Normals require dimension = 3") # compute derivative - T = self.tangent(t, above=above) + T = self.tangent(t, above=above, direction=0) B = self.binormal(t, above=above) - return np.cross(B,T) + return np.cross(B, T) + + @overload + def curvature(self, t: Scalar, above: bool = True) -> float: + ... + + @overload + def curvature(self, t: Scalars, above: bool = True) -> FArray: + ... - def curvature(self, t, above=True): - """ Evaluate the curvaure at specified point(s). The curvature is defined as + def curvature(self, t, above=True): # type: ignore[no-untyped-def] + """Evaluate the curvaure at specified point(s). The curvature is defined as .. math:: \\frac{|\\boldsymbol{v}\\times \\boldsymbol{a}|}{|\\boldsymbol{v}|^3} @@ -209,33 +236,38 @@ def curvature(self, t, above=True): :type t: float or [float] :param bool above: Evaluation in the limit from above :return: Derivative array - :rtype: numpy.array + :rtype: numpy.array or float """ + squeeze = is_singleton(t) + # compute derivative v = self.derivative(t, d=1, above=above) a = self.derivative(t, d=2, above=above) - w = np.cross(v,a) + w = np.cross(v, a) - if len(v.shape) == 1: # single evaluation point - magnitude = np.linalg.norm(w) - speed = np.linalg.norm(v) - else: # multiple evaluation points - if self.dimension == 2: - # for 2D-cases np.cross() outputs scalars - # (the z-component of the cross product) - magnitude = np.abs(w) - else: - # for 3D, it is vectors - magnitude = np.linalg.norm( w, axis= -1) + magnitude: FArray + + if v.ndim == 1 and squeeze: # single evaluation point + return np.linalg.norm(w) / np.linalg.norm(v) - speed = np.linalg.norm( v, axis=-1) + magnitude = np.abs(w) if self.dimension == 2 else np.linalg.norm(w, axis=-1) + speed = np.linalg.norm(v, axis=-1) + return magnitude / np.power(speed, 3) - return magnitude / np.power(speed,3) + @overload + def torsion(self, t: Scalar, above: bool = True) -> float: + ... - def torsion(self, t, above=True): - """ Evaluate the torsion for a 3D curve at specified point(s). The torsion is defined as + @overload + def torsion(self, t: Scalars, above: bool = True) -> FArray: + ... - .. math:: \\frac{(\\boldsymbol{v}\\times \\boldsymbol{a})\\cdot (d\\boldsymbol{a}/dt)}{|\\boldsymbol{v}\\times \\boldsymbol{a}|^2} + def torsion(self, t, above=True): # type: ignore[no-untyped-def] + """Evaluate the torsion for a 3D curve at specified point(s). The torsion is defined as + + .. math:: + \\frac{(\\boldsymbol{v}\\times \\boldsymbol{a}) \\cdot + (d\\boldsymbol{a}/dt)}{|\\boldsymbol{v}\\times \\boldsymbol{a}|^2} :param t: Parametric coordinates in which to evaluate :type t: float or [float] @@ -243,41 +275,41 @@ def torsion(self, t, above=True): :return: Derivative array :rtype: numpy.array """ + squeeze = is_singleton(t) + if self.dimension == 2: # no torsion for 2D curves - t = ensure_listlike(t) + t = ensure_scalars(t) return np.zeros(len(t)) - elif self.dimension == 3: - # only allow 3D curves - pass - else: - raise ValueError('dimension must be 2 or 3') + + if self.dimension != 3: + raise ValueError("dimension must be 2 or 3") # compute derivative - v = self.derivative(t, d=1, above=above) - a = self.derivative(t, d=2, above=above) + v = self.derivative(t, d=1, above=above) + a = self.derivative(t, d=2, above=above) da = self.derivative(t, d=3, above=above) - w = np.cross(v,a) + w = np.cross(v, a) - if len(v.shape) == 1: # single evaluation point + if len(v.shape) == 1 and squeeze: # single evaluation point magnitude = np.linalg.norm(w) - nominator = np.dot(w, a) - else: # multiple evaluation points - magnitude = np.linalg.norm( w, axis=-1) - nominator = np.array([np.dot(w1,da1) for (w1,da1) in zip(w, da)]) + numerator = np.dot(w, a) + else: # multiple evaluation points + magnitude = np.linalg.norm(w, axis=-1) + numerator = np.array([np.dot(w1, da1) for (w1, da1) in zip(w, da)]) - return nominator / np.power(magnitude, 2) + return numerator / np.power(magnitude, 2) - def raise_order(self, amount, direction=None): - """ Raise the polynomial order of the curve. + def raise_order(self, amount: int, direction: Optional[Direction] = None) -> Self: # type: ignore[override] + """Raise the polynomial order of the curve. :param int amount: Number of times to raise the order :return: self """ if amount < 0: - raise ValueError('Raise order requires a non-negative parameter') - elif amount == 0: - return + raise ValueError("Raise order requires a non-negative parameter") + if amount == 0: + return self # create the new basis newBasis = self.bases[0].raise_order(amount) @@ -285,7 +317,7 @@ def raise_order(self, amount, direction=None): # set up an interpolation problem. This is in projective space, so no problems for rational cases interpolation_pts_t = newBasis.greville() # parametric interpolation points (t) N_old = self.bases[0].evaluate(interpolation_pts_t) - N_new = newBasis.evaluate(interpolation_pts_t, sparse=True) + N_new = newBasis.evaluate_sparse(interpolation_pts_t) interpolation_pts_x = N_old @ self.controlpoints # projective interpolation points (x,y,z,w) # solve the interpolation problem @@ -294,8 +326,8 @@ def raise_order(self, amount, direction=None): return self - def append(self, curve): - """ Extend the curve by merging another curve to the end of it. + def append(self, curve: Curve) -> Self: + """Extend the curve by merging another curve to the end of it. The curves are glued together in a C0 fashion with enough repeated knots. The function assumes that the end of this curve perfectly @@ -309,7 +341,7 @@ def append(self, curve): # error test input if self.bases[0].periodic > -1 or curve.bases[0].periodic > -1: - raise RuntimeError('Cannot append with periodic curves') + raise RuntimeError("Cannot append with periodic curves") # copy input curve so we don't change that one directly extending_curve = curve.clone() @@ -327,14 +359,14 @@ def append(self, curve): p = max(p1, p2) # build new knot vector by merging the two existing ones - old_knot = self.knots(direction=0, with_multiplicities=True) - add_knot = extending_curve.knots(direction=0, with_multiplicities=True) + old_knot = self.knots(0, with_multiplicities=True) + add_knot = extending_curve.knots(0, with_multiplicities=True) # make sure that the new one starts where the old one stops add_knot -= add_knot[0] add_knot += old_knot[-1] new_knot = np.zeros(len(add_knot) + len(old_knot) - p - 1) - new_knot[:len(old_knot) - 1] = old_knot[:-1] - new_knot[len(old_knot) - 1:] = add_knot[p:] + new_knot[: len(old_knot) - 1] = old_knot[:-1] + new_knot[len(old_knot) - 1 :] = add_knot[p:] # build new control points by merging the two existing matrices n1 = len(self) @@ -349,23 +381,22 @@ def append(self, curve): return self - def continuity(self, knot): - """ Get the parametric continuity of the curve at a given point. Will + def continuity(self, knot: Scalar) -> int: + """Get the parametric continuity of the curve at a given point. Will return p-1-m, where m is the knot multiplicity and inf between knots""" return self.bases[0].continuity(knot) - def get_kinks(self): - """ Get the parametric coordinates at all points which have C0- + def get_kinks(self) -> list[float]: + """Get the parametric coordinates at all points which have C0- continuity""" - return [k for k in self.knots(0) if self.continuity(k)<1] + return [k for k in self.knots(0) if self.continuity(k) < 1] - def length(self, t0=None, t1=None): - """ Computes the euclidian length of the curve in geometric space + def length(self, t0: Optional[Scalar] = None, t1: Optional[Scalar] = None) -> float: + """Compute the Euclidean length of the curve in geometric space .. math:: \\int_{t_0}^{t_1}\\sqrt{x(t)^2 + y(t)^2 + z(t)^2} dt - """ - (x,w) = np.polynomial.legendre.leggauss(self.order(0)+1) + x, w = np.polynomial.legendre.leggauss(self.order(0) + 1) knots = self.knots(0) # keep only integration boundaries within given start (t0) and stop (t1) interval if t0 is not None: @@ -377,16 +408,16 @@ def length(self, t0=None, t1=None): knots = knots[:i] knots = np.insert(knots, i, t1) - t = np.array([ (x+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots[:-1], knots[1:]) ]) - w = np.array([ w/2*(t1-t0) for t0,t1 in zip(knots[:-1], knots[1:]) ]) - t = np.ndarray.flatten(t) - w = np.ndarray.flatten(w) + t = np.array( + [(x + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots[:-1], knots[1:])], dtype=float + ).flatten() + w = np.array([w / 2 * (t1 - t0) for t0, t1 in zip(knots[:-1], knots[1:])], dtype=float).flatten() dx = self.derivative(t) detJ = np.sqrt(np.sum(dx**2, axis=1)) - return np.dot(detJ, w) + return cast(float, np.dot(detJ, w)) - def rebuild(self, p, n): - """ Creates an approximation to this curve by resampling it using a + def rebuild(self, p: int, n: int) -> Curve: + """Create an approximation to this curve by resampling it using a uniform knot vector of order *p* with *n* control points. :param int p: Polynomial discretization order @@ -394,6 +425,7 @@ def rebuild(self, p, n): :return: A new approximate curve :rtype: Curve """ + # establish uniform open knot vector knot = [0] * p + list(range(1, n - p + 1)) + [n - p + 1] * p basis = BSplineBasis(p, knot) @@ -401,24 +433,25 @@ def rebuild(self, p, n): basis.normalize() t0 = self.bases[0].start() t1 = self.bases[0].end() - basis *= (t1 - t0) + basis *= t1 - t0 basis += t0 # fetch evaluation points and solve interpolation problem t = basis.greville() - N = basis.evaluate(t, sparse=True) + N = basis.evaluate_sparse(t) controlpoints = splinalg.spsolve(N, self.evaluate(t)) # return new resampled curve return Curve(basis, controlpoints) - def error(self, target): - """ Computes the L2 (squared and per knot span) and max error between + def error(self, target: Callable) -> tuple[list[float], float]: + """Computes the L2 (squared and per knot span) and max error between this curve and a target curve .. math:: ||\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)||_{L^2(t_1,t_2)}^2 = \\int_{t_1}^{t_2} |\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)|^2 dt, \\quad \\forall \\;\\text{knots}\\;t_1 < t_2 - .. math:: ||\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)||_{L^\\infty} = \\max_t |\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)| + .. math:: ||\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)||_{L^\\infty} = + \\max_t |\\boldsymbol{x_h}(t)-\\boldsymbol{x}(t)| :param function target: callable function which takes as input a vector of evaluation points t and gives as output a matrix x where @@ -441,20 +474,20 @@ def arclength_circle(t): print('|| e ||_max = ', maxerr) """ knots = self.knots(0) - (x,w) = np.polynomial.legendre.leggauss(self.order(0)+1) - err2 = [] + (x, w) = np.polynomial.legendre.leggauss(self.order(0) + 1) + err2 = [] err_inf = 0.0 - for t0,t1 in zip(knots[:-1], knots[1:]): # for all knot spans - tg = (x+1)/2*(t1-t0)+t0 # evaluation points - wg = w /2*(t1-t0) # integration weights - error = self(tg) - target(tg) # [x-xh, y-yh, z-zh] - error = np.sum(error**2, axis=1) # |x-xh|^2 - err2.append(np.dot(error, wg)) # integrate over domain + for t0, t1 in zip(knots[:-1], knots[1:]): # for all knot spans + tg = (x + 1) / 2 * (t1 - t0) + t0 # evaluation points + wg = w / 2 * (t1 - t0) # integration weights + error = self(tg) - target(tg) # [x-xh, y-yh, z-zh] + error = np.sum(error**2, axis=1) # |x-xh|^2 + err2.append(np.dot(error, wg)) # integrate over domain err_inf = max(np.max(np.sqrt(error)), err_inf) return (err2, err_inf) - def __repr__(self): - return str(self.bases[0]) + '\n' + str(self.controlpoints) + def __repr__(self) -> str: + return str(self.bases[0]) + "\n" + str(self.controlpoints) - def get_derivative_curve(self): - return super(Curve, self).get_derivative_spline(0) + def get_derivative_curve(self) -> Self: + return super().get_derivative_spline(0) diff --git a/splipy/curve_factory.py b/splipy/curve_factory.py index c631f5c..d26e14f 100644 --- a/splipy/curve_factory.py +++ b/splipy/curve_factory.py @@ -1,26 +1,44 @@ -# -*- coding: utf-8 -*- - """Handy utilities for creating curves.""" -from math import pi, cos, sin, sqrt, ceil, atan2 -import copy +from __future__ import annotations + import inspect +from enum import IntEnum +from itertools import chain +from math import ceil, pi, sqrt +from typing import Any, Callable, Literal, Optional, Sequence, cast, overload import numpy as np -from numpy.linalg import norm import scipy.sparse as sp import scipy.sparse.linalg as splinalg +from numpy.linalg import norm -from .curve import Curve -from .basis import BSplineBasis -from .utils import flip_and_move_plane_geometry, rotate_local_x_axis from . import state - -__all__ = ['Boundary', 'line', 'polygon', 'n_gon', 'circle', 'ellipse', - 'circle_segment_from_three_points', 'circle_segment', 'interpolate', - 'least_square_fit', 'cubic_curve', 'bezier', 'manipulate', 'fit'] - -class Boundary: +from .basis import BSplineBasis +from .curve import Curve +from .types import FArray, Scalar, Scalars +from .utils import rotate_local_x_axis +from .utils.curve import curve_length_parametrization + +__all__ = [ + "Boundary", + "line", + "polygon", + "n_gon", + "circle", + "ellipse", + "circle_segment_from_three_points", + "circle_segment", + "interpolate", + "least_square_fit", + "cubic_curve", + "bezier", + "manipulate", + "fit", +] + + +class Boundary(IntEnum): """Enumeration representing different boundary conditions used in :func:`interpolate`.""" @@ -43,8 +61,8 @@ class Boundary: """Use `TANGENT` for the start and `NATURAL` for the end.""" -def line(a, b, relative=False): - """ Create a line between two points. +def line(a: Scalars, b: Scalars, relative: bool = False) -> Curve: + """Create a line between two points. :param array-like a: Start point :param array-like b: End point @@ -53,12 +71,23 @@ def line(a, b, relative=False): :rtype: Curve """ if relative: - b = tuple(ai + bi for ai, bi in zip(a, b)) + c = tuple(ai + bi for ai, bi in zip(a, b)) + return Curve(controlpoints=[a, c]) return Curve(controlpoints=[a, b]) -def polygon(*points, **keywords): - """ Create a linear interpolation between input points. +@overload +def polygon(points: Sequence[Scalars], /, relative: bool = False, t: Optional[Scalars] = None) -> Curve: + ... + + +@overload +def polygon(*points: Scalars, relative: bool = False, t: Optional[Scalars] = None) -> Curve: + ... + + +def polygon(*points: Scalars, relative: bool = False, t: Optional[Scalars] = None) -> Curve: # type: ignore[misc] + """Create a linear interpolation between input points. :param [array-like] points: The points to interpolate :param bool relative: If controlpoints are interpreted as relative to the @@ -67,34 +96,37 @@ def polygon(*points, **keywords): :return: Linear curve through the input points :rtype: Curve """ + + # Handle the first calling convention if len(points) == 1: - points = points[0] - - knot = keywords.get('t', []) - if len(knot) == 0: # establish knot vector based on eucledian length between points - knot = [0, 0] - prevPt = points[0] - for pt in points[1:]: - dist = 0 - for (x0, x1) in zip(prevPt, pt): # loop over (x,y) and maybe z-coordinate - dist += (x1 - x0)**2 - knot.append(knot[-1] + sqrt(dist)) - prevPt = pt - knot.append(knot[-1]) - else: # use knot vector given as input argument - knot = [knot[0]] + list(knot) + [knot[-1]] - - relative = keywords.get('relative', False) + points = cast(tuple[Scalars], points[0]) + + knot: FArray + if t is None: # establish knot vector based on eucledian length between points + knot = np.zeros((2 + len(points),), dtype=float) + curve_length_parametrization(points, buffer=knot[2:-1]) + knot[-1] = knot[-2] + else: # use knot vector given as input argument + knot = np.empty((2 + len(t),), dtype=float) + knot[1:-1] = t + knot[0] = t[0] + knot[-1] = t[-1] + + pts = np.array(points, dtype=float) + if relative: - points = list(points) - for i in range(1, len(points)): - points[i] = [x0 + x1 for (x0,x1) in zip(points[i-1], points[i])] + pts = np.cumsum(pts, axis=0) - return Curve(BSplineBasis(2, knot), points) + return Curve(BSplineBasis(2, knot), pts) -def n_gon(n=5, r=1, center=(0,0,0), normal=(0,0,1)): - """ Create a regular polygon of *n* equal sides centered at the origin. +def n_gon( + n: int = 5, + r: Scalar = 1, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), +) -> Curve: + """Create a regular polygon of *n* equal sides centered at the origin. :param int n: Number of sides and vertices :param float r: Radius @@ -106,24 +138,27 @@ def n_gon(n=5, r=1, center=(0,0,0), normal=(0,0,1)): :raises ValueError: If *n* < 3 """ if r <= 0: - raise ValueError('radius needs to be positive') + raise ValueError("radius needs to be positive") if n < 3: - raise ValueError('regular polygons need at least 3 sides') - - cp = [] - dt = 2 * pi / n - knot = [-1] - for i in range(n): - cp.append([r * cos(i * dt), r * sin(i * dt)]) - knot.append(i) - knot += [n, n+1] + raise ValueError("regular polygons need at least 3 sides") + + angles = np.linspace(0, 2 * pi, num=n, endpoint=False) + cp = np.array([r * np.cos(angles), r * np.sin(angles)]).T + knot = np.arange(-1, n + 2, dtype=float) basis = BSplineBasis(2, knot, 0) - result = Curve(basis, cp) - return flip_and_move_plane_geometry(result, center, normal) + result = Curve(basis, cp) + return result.flip_and_move_plane_geometry(center, normal) + -def circle(r=1, center=(0,0,0), normal=(0,0,1), type='p2C0', xaxis=(1,0,0)): - """ Create a circle. +def circle( + r: Scalar = 1, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + type: Literal["p2C0", "p4C1"] = "p2C0", + xaxis: Scalars = (1, 0, 0), +) -> Curve: + """Create a circle. :param float r: Radius :param array-like center: local origin @@ -135,48 +170,68 @@ def circle(r=1, center=(0,0,0), normal=(0,0,1), type='p2C0', xaxis=(1,0,0)): :raises ValueError: If radius is not positive """ if r <= 0: - raise ValueError('radius needs to be positive') + raise ValueError("radius needs to be positive") - if type == 'p2C0' or type == 'C0p2': + if type == "p2C0" or type == "C0p2": w = 1.0 / sqrt(2) - controlpoints = [[1, 0, 1], - [w, w, w], - [0, 1, 1], - [-w, w, w], - [-1, 0, 1], - [-w, -w, w], - [0, -1, 1], - [w, -w, w]] - knot = np.array([-1, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5]) / 4.0 * 2 * pi - - result = Curve(BSplineBasis(3, knot, 0), controlpoints, True) - elif type.lower() == 'p4c1' or type.lower() == 'c1p4': - w = 2*sqrt(2)/3 - a = 1.0/2/sqrt(2) - b = 1.0/6 * (4*sqrt(2)-1) - controlpoints = [[ 1,-a, 1], - [ 1, a, 1], - [ b, b, w], - [ a, 1, 1], - [-a, 1, 1], - [-b, b, w], - [-1, a, 1], - [-1,-a, 1], - [-b,-b, w], - [-a,-1, 1], - [ a,-1, 1], - [ b,-b, w]] - knot = np.array([ -1, -1, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5]) / 4.0 * 2 * pi - result = Curve(BSplineBasis(5, knot, 1), controlpoints, True) + controlpoints = np.array( + [ + [1, 0, 1], + [w, w, w], + [0, 1, 1], + [-w, w, w], + [-1, 0, 1], + [-w, -w, w], + [0, -1, 1], + [w, -w, w], + ], + dtype=float, + ) + knot = np.array([-1, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5], dtype=float) / 4.0 * 2 * pi + result = Curve(BSplineBasis(3, knot, 0), controlpoints, rational=True) + + elif type.lower() == "p4c1" or type.lower() == "c1p4": + w = 2 * sqrt(2) / 3 + a = 1.0 / 2 / sqrt(2) + b = 1.0 / 6 * (4 * sqrt(2) - 1) + controlpoints = np.array( + [ + [1, -a, 1], + [1, a, 1], + [b, b, w], + [a, 1, 1], + [-a, 1, 1], + [-b, b, w], + [-1, a, 1], + [-1, -a, 1], + [-b, -b, w], + [-a, -1, 1], + [a, -1, 1], + [b, -b, w], + ], + dtype=float, + ) + knot = ( + np.array([-1, -1, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5], dtype=float) / 4.0 * 2 * pi + ) + result = Curve(BSplineBasis(5, knot, 1), controlpoints, rational=True) else: - raise ValueError('Unkown type: %s' %(type)) + raise ValueError("Unkown type: %s" % (type)) result *= r result.rotate(rotate_local_x_axis(xaxis, normal)) - return flip_and_move_plane_geometry(result, center, normal) + return result.flip_and_move_plane_geometry(center, normal) + -def ellipse(r1=1, r2=1, center=(0,0,0), normal=(0,0,1), type='p2C0', xaxis=(1,0,0)): - """ Create an ellipse +def ellipse( + r1: Scalar = 1, + r2: Scalar = 1, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + type: Literal["p2C0", "p4C1"] = "p2C0", + xaxis: Scalars = (1, 0, 0), +) -> Curve: + """Create an ellipse. :param float r1: Radius along xaxis :param float r2: Radius orthogonal to xaxis @@ -189,14 +244,13 @@ def ellipse(r1=1, r2=1, center=(0,0,0), normal=(0,0,1), type='p2C0', xaxis=(1,0, :raises ValueError: If radius is not positive """ result = circle(type=type) - result *= [r1,r2,1] + result *= [r1, r2, 1] result.rotate(rotate_local_x_axis(xaxis, normal)) - return flip_and_move_plane_geometry(result, center, normal) + return result.flip_and_move_plane_geometry(center, normal) -def circle_segment_from_three_points(x0, x1, x2): - """circle_segment_from_three_points(x0, x1, x2) - Create a circle segment going from the point x0 to x2 through x1 +def circle_segment_from_three_points(x0: Scalars, x1: Scalars, x2: Scalars) -> Curve: + """Create a circle segment going from the point x0 to x2 through x1 :param array-like x0: The start point (2D or 3D point) :param array-like x1: An intermediate point (2D or 3D) @@ -204,44 +258,58 @@ def circle_segment_from_three_points(x0, x1, x2): :rtype: Curve """ # wrap input into 3d numpy arrays - pt0 = np.array([0,0,0], dtype='float') - pt1 = np.array([0,0,0], dtype='float') - pt2 = np.array([0,0,0], dtype='float') - pt0[:len(x0)] = x0 - pt1[:len(x1)] = x1 - pt2[:len(x2)] = x2 + pt0 = np.array([0, 0, 0], dtype=float) + pt1 = np.array([0, 0, 0], dtype=float) + pt2 = np.array([0, 0, 0], dtype=float) + pt0[: len(x0)] = x0 + pt1[: len(x1)] = x1 + pt2[: len(x2)] = x2 # figure out normal, center and radius - normal = np.cross(pt1-pt0, pt2-pt0) - A = np.vstack((2*(pt1-pt0), 2*(pt2-pt0), normal)) - b = np.array([ np.dot(pt1,pt1) - np.dot(pt0,pt0), - np.dot(pt2,pt2) - np.dot(pt0,pt0), - np.dot(normal,pt0)]) - center = np.linalg.solve(A,b) - radius = norm(pt2-center) - v2 = pt2-center - v1 = pt1-center - v0 = pt0-center - w0 = pt0-pt2 - w1 = pt1-pt2 + normal = np.cross(pt1 - pt0, pt2 - pt0) + A = np.vstack((2 * (pt1 - pt0), 2 * (pt2 - pt0), normal)) + b = np.array( + [ + np.dot(pt1, pt1) - np.dot(pt0, pt0), + np.dot(pt2, pt2) - np.dot(pt0, pt0), + np.dot(normal, pt0), + ], + dtype=float, + ) + center = np.linalg.solve(A, b) + radius = norm(pt2 - center) + v2 = pt2 - center + v1 = pt1 - center + v0 = pt0 - center + w0 = pt0 - pt2 + w1 = pt1 - pt2 w2 = np.cross(w0, w1) - normal = np.cross(v0,v2) + normal = np.cross(v0, v2) len_v2 = norm(v2) len_v0 = norm(v0) - theta = np.arccos(np.dot(v2,v0) / len_v2 / len_v0) - if not np.all([np.sign(i)==np.sign(j) or abs(i-j) < state.controlpoint_absolute_tolerance for (i,j) in zip(w2,normal)]): - theta = 2*pi - theta + theta = np.arccos(np.dot(v2, v0) / len_v2 / len_v0) + if not all( + np.sign(i) == np.sign(j) or abs(i - j) < state.controlpoint_absolute_tolerance + for i, j in zip(w2, normal) + ): + theta = 2 * pi - theta normal = -normal - result = circle_segment(theta, radius, center, np.cross(v0,v1), v0) + result = circle_segment(theta, radius, center, np.cross(v0, v1), v0) # spit out 2D curve if all input points were 2D, otherwise return 3D - result.set_dimension(np.max([len(x0), len(x1), len(x2)])) + result.set_dimension(max(len(x0), len(x1), len(x2))) return result -def circle_segment(theta, r=1, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0)): - """ Create a circle segment starting parallel to the rotated x-axis. +def circle_segment( + theta: Scalar, + r: Scalar = 1, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), +) -> Curve: + """Create a circle segment starting parallel to the rotated x-axis. :param float theta: Angle in radians :param float r: Radius @@ -253,45 +321,47 @@ def circle_segment(theta, r=1, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0)): :raises ValueError: If radius is not positive :raises ValueError: If theta is not in the range *[-2pi, 2pi]* """ - # error test input - if abs(theta) > 2 * pi: - raise ValueError('theta needs to be in range [-2pi,2pi]') + + # Error test input + if np.abs(theta) > 2 * pi: + raise ValueError("theta needs to be in range [-2pi,2pi]") if r <= 0: - raise ValueError('radius needs to be positive') - if theta == 2*pi: + raise ValueError("radius needs to be positive") + if theta == 2 * pi: return circle(r, center, normal) - # build knot vector - knot_spans = int(ceil(abs(theta) / (2 * pi / 3))) - knot = [0] - for i in range(knot_spans + 1): - knot += [i] * 2 - knot += [knot_spans] # knot vector [0,0,0,1,1,2,2,..,n,n,n] - knot = np.array(knot) / float(knot[-1]) * theta # set parametric space to [0,theta] + # Build knot vector + knot_spans = int(ceil(np.abs(theta) / (2 * pi / 3))) + knot = np.empty((2 * knot_spans + 4,), dtype=float) + knot[0] = 0 + knot[1:-1:2] = np.arange(knot_spans + 1) + knot[2::2] = np.arange(knot_spans + 1) + knot[-1] = knot_spans + knot *= theta / knot_spans n = (knot_spans - 1) * 2 + 3 # number of control points needed - cp = [] - t = 0 # current angle dt = float(theta) / knot_spans / 2 # angle step + angles = np.linspace(0, dt * n, num=n, endpoint=False) - # build control points - for i in range(n): - w = 1 - (i % 2) * (1 - cos(dt)) # weights = 1 and cos(dt) every other i - x = r * cos(t) - y = r * sin(t) - cp += [[x, y, w]] - t += dt + cp = np.array( + [ + r * np.cos(angles), + r * np.sin(angles), + 1 - (np.arange(n, dtype=int) % 2) * (1 - np.cos(dt)), + ] + ).T if theta < 0: - cp.reverse() - result = Curve(BSplineBasis(3, np.flip(knot,0)), cp, True) + cp = cp[::-1] + result = Curve(BSplineBasis(3, np.flip(knot, 0)), cp, rational=True) else: - result = Curve(BSplineBasis(3, knot), cp, True) + result = Curve(BSplineBasis(3, knot), cp, rational=True) result.rotate(rotate_local_x_axis(xaxis, normal)) - return flip_and_move_plane_geometry(result, center, normal) + return result.flip_and_move_plane_geometry(center, normal) + -def interpolate(x, basis, t=None): - """ Perform general spline interpolation on a provided basis. +def interpolate(x: Any, basis: BSplineBasis, t: Optional[Scalars] = None) -> Curve: + """Perform general spline interpolation on a provided basis. :param matrix-like x: Matrix *X[i,j]* of interpolation points *xi* with components *j* @@ -301,22 +371,23 @@ def interpolate(x, basis, t=None): :return: Interpolated curve :rtype: Curve """ - # wrap input into an array - x = np.array(x) + # Wrap input into an array + x = np.array(x, dtype=float) - # evaluate all basis functions in the interpolation points + # Evaluate all basis functions in the interpolation points if t is None: t = basis.greville() N = basis.evaluate(t, sparse=True) - # solve interpolation problem + # Solve interpolation problem cp = splinalg.spsolve(N, x) cp = cp.reshape(x.shape) return Curve(basis, cp) -def least_square_fit(x, basis, t): - """ Perform a least-square fit of a point cloud onto a spline basis + +def least_square_fit(x: Any, basis: BSplineBasis, t: Scalars) -> Curve: + """Perform a least-square fit of a point cloud onto a spline basis :param matrix-like x: Matrix *X[i,j]* of interpolation points *xi* with components *j*. The number of points must be equal to or larger than @@ -331,13 +402,18 @@ def least_square_fit(x, basis, t): N = basis.evaluate(t) # solve interpolation problem - controlpoints,_,_,_ = np.linalg.lstsq(N, x, rcond=None) + controlpoints, _, _, _ = np.linalg.lstsq(N, x, rcond=None) return Curve(basis, controlpoints) -def cubic_curve(x, boundary=Boundary.FREE, t=None, tangents=None): - """ Perform cubic spline interpolation on a provided basis. +def cubic_curve( + x: FArray, + boundary: Boundary = Boundary.FREE, + t: Optional[Scalars] = None, + tangents: Optional[FArray] = None, +) -> Curve: + """Perform cubic spline interpolation on a provided basis. The valid boundary conditions are enumerated in :class:`Boundary`. The meaning of the `tangents` parameter depends on the specified boundary @@ -358,83 +434,89 @@ def cubic_curve(x, boundary=Boundary.FREE, t=None, tangents=None): :rtype: Curve """ - # if periodic input is not closed, make sure we do it now + # If periodic input is not closed, make sure we do it now + append_knots: list[Scalar] = [] if boundary == Boundary.PERIODIC and not ( - np.allclose(x[0,:], x[-1,:], - rtol = state.controlpoint_relative_tolerance, - atol = state.controlpoint_absolute_tolerance)): - x = np.append(x, [x[0,:]], axis=0) + np.allclose( + x[0, :], + x[-1, :], + rtol=state.controlpoint_relative_tolerance, + atol=state.controlpoint_absolute_tolerance, + ) + ): + x = np.append(x, [x[0, :]], axis=0) if t is not None: - # augment interpolation knot by euclidian distance to end - t = list(t) + [t[-1] + norm(np.array(x[0,:])- np.array(x[-2,:]))] + # Augment interpolation knot by euclidian distance to end + append_knots = [t[-1] + norm(np.array(x[0, :]) - np.array(x[-2, :]))] - n = len(x) if t is None: - t = [0.0] - for (x0,x1) in zip(x[:-1,:], x[1:,:]): - # eucledian distance between two consecutive points - dist = norm(np.array(x1)-np.array(x0)) - t.append(t[-1]+dist) - - # modify knot vector for chosen boundary conditions - knot = [t[0]]*3 + list(t) + [t[-1]]*3 + knot = np.zeros(6 + len(x), dtype=float) + curve_length_parametrization(x, buffer=knot[4:-3]) + else: + last = append_knots[-1] if append_knots else t[-1] + knot = np.fromiter(chain([t[0]] * 3, t, append_knots, [last] * 3), dtype=float) + + # Modify knot vector for chosen boundary conditions + iknot = knot[3:-3] if boundary == Boundary.FREE: - del knot[-5] - del knot[4] + knot = np.delete(knot, [4, -5]) elif boundary == Boundary.HERMITE: - knot = sorted(list(knot) + list(t[1:-1])) + knot = np.append(knot, knot[4:-4]) + knot.sort() - # create the interpolation basis and interpolation matrix on this + # Create the interpolation basis and interpolation matrix on this if boundary == Boundary.PERIODIC: # C2-periodic knots - knot[0] = t[0] + t[-4] - t[-1] - knot[1] = t[0] + t[-3] - t[-1] - knot[2] = t[0] + t[-2] - t[-1] - knot[-3] = t[-1] + t[1] - t[0] - knot[-2] = t[-1] + t[2] - t[0] - knot[-1] = t[-1] + t[3] - t[0] + knot[0] = iknot[0] + iknot[-4] - iknot[-1] + knot[1] = iknot[0] + iknot[-3] - iknot[-1] + knot[2] = iknot[0] + iknot[-2] - iknot[-1] + knot[-3] = iknot[-1] + iknot[1] - iknot[0] + knot[-2] = iknot[-1] + iknot[2] - iknot[0] + knot[-1] = iknot[-1] + iknot[3] - iknot[0] basis = BSplineBasis(4, knot, 2) # do not duplicate the interpolation at the sem (start=end is the same point) # identical points equal singular interpolation matrix - t = t[:-1] - x = x[:-1,:] + iknot = iknot[:-1] + x = x[:-1, :] else: basis = BSplineBasis(4, knot) - N = basis(t, sparse=True) # left-hand-side matrix + N = basis(iknot, sparse=True) # left-hand-side matrix - # add derivative boundary conditions if applicable + # Add derivative boundary conditions if applicable if boundary in [Boundary.TANGENT, Boundary.HERMITE, Boundary.TANGENTNATURAL]: if boundary == Boundary.TANGENT: - dn = basis([t[0], t[-1]], d=1) + dn = basis([iknot[0], iknot[-1]], d=1) elif boundary == Boundary.TANGENTNATURAL: - dn = basis(t[0], d=1) + dn = basis(iknot[0], d=1) elif boundary == Boundary.HERMITE: - dn = basis(t, d=1) - N = sp.vstack([N, sp.csr_matrix(dn)]) - x = np.vstack([x, tangents]) + dn = basis(iknot, d=1) + N = sp.vstack([N, sp.csr_matrix(dn)]) + assert tangents is not None + x = np.vstack([x, tangents]) - # add double derivative boundary conditions if applicable + # Add double derivative boundary conditions if applicable if boundary in [Boundary.NATURAL, Boundary.TANGENTNATURAL]: if boundary == Boundary.NATURAL: - ddn = basis([t[0], t[-1]], d=2) - new = 2 + ddn = basis([iknot[0], iknot[-1]], d=2) + new = 2 elif boundary == Boundary.TANGENTNATURAL: - ddn = basis(t[-1], d=2) - new = 1 - N = sp.vstack([N, sp.csr_matrix(ddn)]) - x = np.vstack([x, np.zeros((new, x.shape[1]))]) + ddn = basis(iknot[-1], d=2) + new = 1 + N = sp.vstack([N, sp.csr_matrix(ddn)]) + x = np.vstack([x, np.zeros((new, x.shape[1]))]) - # solve system to get controlpoints - cp = splinalg.spsolve(N,x) + # Solve system to get controlpoints + cp = splinalg.spsolve(N, x) cp = cp.reshape(x.shape) - # wrap it all into a curve and return + # Wrap it all into a curve and return return Curve(basis, cp) -def bezier(pts, quadratic=False, relative=False): - """ Generate a cubic or quadratic bezier curve from a set of control points + +def bezier(pts: Any, quadratic: bool = False, relative: bool = False) -> Curve: + """Generate a cubic or quadratic bezier curve from a set of control points :param [array-like] pts: list of control-points. In addition to a starting point we need three points per bezier interval for cubic splines and @@ -445,25 +527,34 @@ def bezier(pts, quadratic=False, relative=False): previous one :return: Bezier curve :rtype: Curve - """ - if quadratic: - p = 3 - else: - p = 4 - # compute number of intervals - n = int((len(pts)-1)/(p-1)) - # generate uniform knot vector of repeated integers - knot = list(range(n+1)) * (p-1) + [0, n] - knot.sort() + p = 3 if quadratic else 4 + + # Compute number of intervals + n = (len(pts) - 1) // (p - 1) + + # Generate uniform knot vector of repeated integers + knot = np.empty(((n + 1) * (p - 1) + 2,), dtype=float) + knot[0] = 0 + for i in range(1, p): + knot[i : -1 : p - 1] = np.arange(n + 1) + knot[-1] = n + + cps = np.array(pts, dtype=float) + if relative: - pts = copy.deepcopy(pts) - for i in range(1, len(pts)): - pts[i] = [x0 + x1 for (x0,x1) in zip(pts[i-1], pts[i])] - return Curve(BSplineBasis(p, knot), pts) + cps = np.cumsum(cps, axis=0) + + return Curve(BSplineBasis(p, knot), cps) + -def manipulate(crv, f, normalized=False, vectorized=False): - """ Create a new curve based on an expression-evaluation of an existing one +def manipulate( + crv: Curve, + f: Callable, + normalized: bool = False, + vectorized: bool = False, +) -> Curve: + """Create a new curve based on an expression-evaluation of an existing one :param Curve crv: original curve on which f is to be applied :param function f: expression of the physical point *x*, the velocity (tangent) *v*, parametric point *t* and/or acceleration *a*. @@ -505,46 +596,49 @@ def move_3_to_right_fast(x, v): t = np.array(b.greville()) n = len(crv) + argv: list[Any] + if vectorized: x = crv(t) - arg_names = inspect.getargspec(f).args + arg_names = inspect.getfullargspec(f).args argc = len(arg_names) argv = [0] * argc for j in range(argc): - if arg_names[j] == 'x': + if arg_names[j] == "x": argv[j] = x - elif arg_names[j] == 't': + elif arg_names[j] == "t": argv[j] = t - elif arg_names[j] == 'v': + elif arg_names[j] == "v": c0 = np.array([i for i in range(n) if b.continuity(t[i]) == 0]) v = crv.derivative(t, 1) - if len(c0)>0: - v[c0,:] = (v[c0,:] + crv.derivative(t[c0], 1, above=False)) / 2.0 + if len(c0) > 0: + v[c0, :] = (v[c0, :] + crv.derivative(t[c0], 1, above=False)) / 2.0 if normalized: v[:] = [vel / norm(vel) for vel in v] argv[j] = v - elif arg_names[j] == 'a': + elif arg_names[j] == "a": c1 = np.array([i for i in range(n) if b.continuity(t[i]) < 2]) a = crv.derivative(t, 2) - if len(c1)>0: - a[c1,:] = (a[c1,:] + crv.derivative(t[c1], 2, above=False)) / 2.0 + if len(c1) > 0: + a[c1, :] = (a[c1, :] + crv.derivative(t[c1], 2, above=False)) / 2.0 if normalized: a[:] = [acc / norm(acc) for acc in a] argv[j] = a - destination = f(*argv) + destination = f(*argv) + else: destination = np.zeros((len(crv), crv.dimension)) - for (t1, i) in zip(t, range(len(t))): + for t1, i in zip(t, range(len(t))): x = crv(t1) - arg_names = inspect.getargspec(f).args + arg_names = inspect.getfullargspec(f).args argc = len(arg_names) argv = [0] * argc for j in range(argc): - if arg_names[j] == 'x': + if arg_names[j] == "x": argv[j] = x - elif arg_names[j] == 't': + elif arg_names[j] == "t": argv[j] = t1 - elif arg_names[j] == 'v': + elif arg_names[j] == "v": v = crv.derivative(t1, 1) if b.continuity(t1) < 1: v += crv.derivative(t1, 1, above=False) @@ -552,7 +646,7 @@ def move_3_to_right_fast(x, v): if normalized: v /= norm(v) argv[j] = v - elif arg_names[j] == 'a': + elif arg_names[j] == "a": a = crv.derivative(t1, 2) if b.continuity(t1) < 2: a += crv.derivative(t1, 1, above=False) @@ -566,8 +660,15 @@ def move_3_to_right_fast(x, v): controlpoints = splinalg.spsolve(N, destination) return Curve(b, controlpoints) -def fit(x, t0, t1, rtol=1e-4, atol=0.0): - """ Computes an interpolation for a parametric curve up to a specified tolerance. + +def fit( + x: Callable, + t0: Scalar, + t1: Scalar, + rtol: Scalar = 1e-4, + atol: Scalar = 0.0, +) -> Curve: + """Compute an interpolation for a parametric curve up to a specified tolerance. The method will iteratively refine parts where needed resulting in a non-uniform knot vector with as optimized knot locations as possible. @@ -613,41 +714,47 @@ def move_along_tangent(t): crv2 = curve_factory.fit(move_along_tangent, crv.start(0), crv.end(0)) """ - b = BSplineBasis(4, [t0,t0,t0,t0, t1,t1,t1,t1]) + b = BSplineBasis(4, np.array([t0, t0, t0, t0, t1, t1, t1, t1], dtype=float)) t = np.array(b.greville()) crv = interpolate(x(t), b, t) (err2, maxerr) = crv.error(x) - # polynomial input (which can be exactly represented) only use one knot span + + # Polynomial input (which can be exactly represented) only use one knot span if maxerr < 1e-13: return crv - # for all other curves, start with 4 knot spans - knot_vector = [t0,t0,t0,t0] + [i/5.0*(t1-t0)+t0 for i in range(1,5)] + [t1,t1,t1,t1] + # For all other curves, start with 4 knot spans + knot_vector = np.array( + [t0, t0, t0, t0] + [i / 5.0 * (t1 - t0) + t0 for i in range(1, 5)] + [t1, t1, t1, t1], dtype=float + ) b = BSplineBasis(4, knot_vector) t = np.array(b.greville()) crv = interpolate(x(t), b, t) (err2, maxerr) = crv.error(x) - # this is technically false since we need the length of the target function *x* + + # This is technically false since we need the length of the target function *x* # and not our approximation *crv*, but we don't have the derivative of *x*, so - # we can't compute it. This seems like a healthy compromise + # we can't compute it. This seems like a healthy compromise. length = crv.length() - while np.sqrt(np.sum(err2))/length > rtol and maxerr > atol: - knot_span = crv.knots(0) # knot vector without multiplicities - target_error = (rtol*length)**2 / len(err2) # equidistribute error among all knot spans - refinements = [] + while np.sqrt(np.sum(err2)) / length > rtol and maxerr > atol: + knot_span = crv.knots(0) # knot vector without multiplicities + target_error = (rtol * length) ** 2 / len(err2) # equidistribute error among all knot spans + for i in range(len(err2)): # figure out how many new knots we require in this knot interval: # if we converge with *scale* and want an error of *target_error* # |e|^2 * (1/n)^scale = target_error^2 - conv_order = 4 # cubic interpolateion is order=4 - square_conv_order = 2*conv_order # we are computing with square of error - scale = square_conv_order + 4 # don't want to converge too quickly in case of highly non-uniform mesh refinement is required - n = int(np.ceil(np.exp((np.log(err2[i]) - np.log(target_error))/scale))) + conv_order = 4 # cubic interpolateion is order=4 + square_conv_order = 2 * conv_order # we are computing with square of error + scale = ( + square_conv_order + 4 + ) # don't want to converge too quickly in case of highly non-uniform mesh refinement is required + n = int(np.ceil(np.exp((np.log(err2[i]) - np.log(target_error)) / scale))) # add *n* new interior knots to this knot span - new_knots = np.linspace(knot_span[i], knot_span[i+1], n+1) - knot_vector = knot_vector + list(new_knots[1:-1]) + new_knots = np.linspace(knot_span[i], knot_span[i + 1], n + 1) + knot_vector = np.append(knot_vector, new_knots[1:-1]) # build new refined knot vector knot_vector.sort() @@ -660,8 +767,14 @@ def move_along_tangent(t): return crv -def fit_points(x, t=[], rtol=1e-4, atol=0.0): - """ Computes an approximation for a list of points up to a specified tolerance. + +def fit_points( + x: Sequence[Scalars], + t: Optional[Scalars] = None, + rtol: Scalar = 1e-4, + atol: Scalar = 0.0, +) -> Curve: + """Compute an approximation for a list of points up to a specified tolerance. The method will iteratively refine parts where needed resulting in a non-uniform knot vector with as optimized knot locations as possible. The target curve is the linear interpolation of the input points @@ -676,8 +789,5 @@ def fit_points(x, t=[], rtol=1e-4, atol=0.0): :return: Curve Non-uniform cubic B-spline curve """ - if len(t)>0: - linear = polygon(x, t=t) - else: - linear = polygon(x) + linear = polygon(x, t=t) return fit(linear, linear.start(0), linear.end(0), rtol=rtol, atol=atol) diff --git a/splipy/io/__init__.py b/splipy/io/__init__.py index a927997..f50f126 100644 --- a/splipy/io/__init__.py +++ b/splipy/io/__init__.py @@ -1,30 +1,27 @@ -from .g2 import G2 -from .svg import SVG +from .g2 import G2 +from .ofoam import OpenFOAM from .spl import SPL from .stl import STL -from .ofoam import OpenFOAM +from .svg import SVG +__all__ = ["G2", "SVG", "SPL", "STL", "OpenFOAM"] -# GRDECL depends on optional cv2 -try: - from .grdecl import GRDECL - has_grdecl = True -except ImportError: - has_grdecl = False +from importlib.util import find_spec +has_cv2 = find_spec("cv2") -# ThreeDM depends on optional rhino3dm -try: - from .threedm import ThreeDM - has_rhino = True -except ImportError: - has_rhino = False +# GRDECL depends on optional cv2 +has_grdecl = has_cv2 +if has_grdecl: + from .grdecl import GRDECL # noqa -__all__ = ['G2', 'SVG', 'SPL', 'STL', 'OpenFOAM'] + __all__.append("GRDECL") -if has_grdecl: - __all__.append('GRDECL') +# ThreeDM depends on optional rhino3dm +has_rhino = find_spec("rhino3dm") if has_rhino: - __all__.append('ThreeDM') + from .threedm import ThreeDM # noqa + + __all__.append("ThreeDM") diff --git a/splipy/io/g2.py b/splipy/io/g2.py index f81e453..241f1c5 100644 --- a/splipy/io/g2.py +++ b/splipy/io/g2.py @@ -1,36 +1,142 @@ -from itertools import chain, product +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Literal, Optional, Sequence, TextIO, Union import numpy as np -from numpy import sqrt, pi, savetxt +from numpy import pi, savetxt +from typing_extensions import Self -from ..curve import Curve -from ..surface import Surface -from ..volume import Volume -from ..splineobject import SplineObject -from ..basis import BSplineBasis -from ..trimmedsurface import TrimmedSurface -from ..utils import flip_and_move_plane_geometry, rotate_local_x_axis -from .. import surface_factory, curve_factory, state +from splipy import curve_factory, state, surface_factory +from splipy.basis import BSplineBasis +from splipy.splinemodel import SplineModel +from splipy.splineobject import SplineObject +from splipy.surface import Surface +from splipy.trimmedsurface import TrimmedSurface +from splipy.utils import rotate_local_x_axis from .master import MasterIO +if TYPE_CHECKING: + from types import TracebackType + + from splipy.curve import Curve + class G2(MasterIO): + fstream: TextIO + filename: Path + mode: Literal["w", "r"] + trimming_curves: list[TrimmedSurface] + + g2_type: ClassVar[list[int]] = [100, 200, 700] # curve, surface, volume identifiers + g2_generators: ClassVar[dict[int, str]] = { + 120: "line", + 130: "circle", + 140: "ellipse", + 260: "cylinder", + 292: "disc", + 270: "sphere", + 290: "torus", + 250: "plane", + 210: "bounded_surface", + 261: "surface_of_linear_extrusion", + } # , 280:cone + + def __init__(self, filename: Union[Path, str], mode: Literal["w", "r"] = "r") -> None: + self.filename = Path(filename) + self.trimming_curves = [] + self.mode = mode + + def __enter__(self) -> Self: + self.fstream = self.filename.open(self.mode).__enter__() + return self + + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.fstream.__exit__(exc_type, exc_val, exc_tb) + + def _write_obj(self, obj: SplineObject) -> None: + for i in range(obj.pardim): + if obj.periodic(i): + obj = obj.split_periodic(obj.start(i), i) + + self.fstream.write(f"{G2.g2_type[obj.pardim - 1]} 1 0 0\n") + self.fstream.write(f"{obj.dimension} {int(obj.rational)}\n") + for b in obj.bases: + self.fstream.write("%i %i\n" % (len(b.knots) - b.order, b.order)) + self.fstream.write(" ".join("%.16g" % k for k in b.knots)) + self.fstream.write("\n") + + savetxt( + self.fstream, + obj.controlpoints.reshape(-1, obj.dimension + obj.rational, order="F"), + fmt="%.16g", + delimiter=" ", + newline="\n", + ) + + def write(self, obj: Union[Sequence[SplineObject], SplineObject, SplineModel]) -> None: + """Write the object in GoTools format.""" + if isinstance(obj, (Sequence, SplineModel)): # input SplineModel or list + for o in obj: + self._write_obj(o) + return + + assert isinstance(obj, SplineObject) + self._write_obj(obj) + + def read(self) -> list[SplineObject]: + result = [] + + for line in self.fstream: + line = line.strip() + if not line: + continue + + # read object type + objtype, major, minor, patch = map(int, line.split()) + if (major, minor, patch) != (1, 0, 0): + raise OSError("Unknown G2 format") + + # if obj type is in factory methods (cicle, torus etc), create it now + if objtype in G2.g2_generators: + constructor = getattr(self, G2.g2_generators[objtype]) + result.append(constructor()) + continue + + # for "normal" splines (Curves, Surfaces, Volumes) create it now + pardim = [i for i in range(len(G2.g2_type)) if G2.g2_type[i] == objtype] + if not pardim: + raise OSError(f"Unknown G2 object type {objtype}") + result.append(self.splines(pardim[0] + 1)) - def read_next_non_whitespace(self): + return result + + def read_basis(self) -> BSplineBasis: + ncps, order = map(int, next(self.fstream).split()) + kts = list(map(float, next(self.fstream).split())) + return BSplineBasis(order, kts, -1) + + def read_next_non_whitespace(self) -> str: line = next(self.fstream).strip() while not line: line = next(self.fstream).strip() return line - def circle(self): - dim = int( self.read_next_non_whitespace().strip()) - r = float( next(self.fstream).strip()) - center= np.array(next(self.fstream).split(), dtype=float) - normal= np.array(next(self.fstream).split(), dtype=float) + def circle(self) -> Curve: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + r = float(next(self.fstream).strip()) + center = np.array(next(self.fstream).split(), dtype=float) + normal = np.array(next(self.fstream).split(), dtype=float) xaxis = np.array(next(self.fstream).split(), dtype=float) param = np.array(next(self.fstream).split(), dtype=float) - reverse = next(self.fstream).strip() != '0' + reverse = next(self.fstream).strip() != "0" result = curve_factory.circle(r=r, center=center, normal=normal, xaxis=xaxis) result.reparam(param) @@ -38,15 +144,16 @@ def circle(self): result.reverse() return result - def ellipse(self): - dim = int( self.read_next_non_whitespace().strip()) - r1 = float( next(self.fstream).strip()) - r2 = float( next(self.fstream).strip()) - center= np.array(next(self.fstream).split(), dtype=float) - normal= np.array(next(self.fstream).split(), dtype=float) + def ellipse(self) -> Curve: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + r1 = float(next(self.fstream).strip()) + r2 = float(next(self.fstream).strip()) + center = np.array(next(self.fstream).split(), dtype=float) + normal = np.array(next(self.fstream).split(), dtype=float) xaxis = np.array(next(self.fstream).split(), dtype=float) param = np.array(next(self.fstream).split(), dtype=float) - reverse = next(self.fstream).strip() != '0' + reverse = next(self.fstream).strip() != "0" result = curve_factory.ellipse(r1=r1, r2=r2, center=center, normal=normal, xaxis=xaxis) result.reparam(param) @@ -54,132 +161,137 @@ def ellipse(self): result.reverse() return result - def line(self): - dim = int( self.read_next_non_whitespace().strip()) - start = np.array(next(self.fstream).split(), dtype=float) - direction= np.array(next(self.fstream).split(), dtype=float) - finite = next(self.fstream).strip() != '0' - param = np.array(next(self.fstream).split(), dtype=float) - reverse = next(self.fstream).strip() != '0' + def line(self) -> Curve: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + start = np.array(next(self.fstream).split(), dtype=float) + direction = np.array(next(self.fstream).split(), dtype=float) + finite = next(self.fstream).strip() != "0" + param = np.array(next(self.fstream).split(), dtype=float) + reverse = next(self.fstream).strip() != "0" d = np.array(direction) s = np.array(start) # d /= np.linalg.norm(d) if not finite: - param = [-state.unlimited, +state.unlimited] + param = np.array([-state.unlimited, +state.unlimited]) - result = curve_factory.line(s+d*param[0], s+d*param[1]) + result = curve_factory.line(s + d * param[0], s + d * param[1]) if reverse: result.reverse() return result - -# def cone(self): -# dim = int( self.read_next_non_whitespace().strip()) -# r = float( next(self.fstream).strip()) -# center = np.array(next(self.fstream).split(' '), dtype=float) -# z_axis = np.array(next(self.fstream).split(' '), dtype=float) -# x_axis = np.array(next(self.fstream).split(' '), dtype=float) -# angle = float( next(self.fstream).strip()) -# finite = next(self.fstream).strip() != '0' -# param_u = np.array(next(self.fstream).split(' '), dtype=float) -# if finite: -# param_v=np.array(next(self.fstream).split(' '), dtype=float) - - - def cylinder(self): - dim = int( self.read_next_non_whitespace().strip()) - r = float( next(self.fstream).strip()) - center = np.array(next(self.fstream).split(), dtype=float) - z_axis = np.array(next(self.fstream).split(), dtype=float) - x_axis = np.array(next(self.fstream).split(), dtype=float) - finite = next(self.fstream).strip() != '0' - param_u = np.array(next(self.fstream).split(), dtype=float) + # def cone(self): + # dim = int( self.read_next_non_whitespace().strip()) + # r = float( next(self.fstream).strip()) + # center = np.array(next(self.fstream).split(' '), dtype=float) + # z_axis = np.array(next(self.fstream).split(' '), dtype=float) + # x_axis = np.array(next(self.fstream).split(' '), dtype=float) + # angle = float( next(self.fstream).strip()) + # finite = next(self.fstream).strip() != '0' + # param_u = np.array(next(self.fstream).split(' '), dtype=float) + # if finite: + # param_v=np.array(next(self.fstream).split(' '), dtype=float) + + def cylinder(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + r = float(next(self.fstream).strip()) + center = np.array(next(self.fstream).split(), dtype=float) + z_axis = np.array(next(self.fstream).split(), dtype=float) + x_axis = np.array(next(self.fstream).split(), dtype=float) + finite = next(self.fstream).strip() != "0" + param_u = np.array(next(self.fstream).split(), dtype=float) if finite: - param_v=np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) else: - param_v=[-state.unlimited, state.unlimited] - swap = next(self.fstream).strip() != '0' + param_v = np.array([-state.unlimited, state.unlimited]) + swap = next(self.fstream).strip() != "0" - center = center + z_axis*param_v[0] - h = param_v[1] - param_v[0] + center = center + z_axis * param_v[0] + h = param_v[1] - param_v[0] result = surface_factory.cylinder(r=r, center=center, xaxis=x_axis, axis=z_axis, h=h) result.reparam(param_u, param_v) if swap: result.swap() return result - def disc(self): - dim = int( self.read_next_non_whitespace().strip()) - center = np.array(next(self.fstream).split(), dtype=float) - r = float( next(self.fstream).strip()) - z_axis = np.array(next(self.fstream).split(), dtype=float) - x_axis = np.array(next(self.fstream).split(), dtype=float) - degen = next(self.fstream).strip() != '0' - angles = [float( next(self.fstream).strip()) for i in range(4)] - param_u = np.array(next(self.fstream).split(), dtype=float) - param_v = np.array(next(self.fstream).split(), dtype=float) - swap = next(self.fstream).strip() != '0' + def disc(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + center = np.array(next(self.fstream).split(), dtype=float) + r = float(next(self.fstream).strip()) + z_axis = np.array(next(self.fstream).split(), dtype=float) + x_axis = np.array(next(self.fstream).split(), dtype=float) + degen = next(self.fstream).strip() != "0" + angles = [float(next(self.fstream).strip()) for i in range(4)] + param_u = np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) + swap = next(self.fstream).strip() != "0" if degen: - result = surface_factory.disc(r=r, center=center, xaxis=x_axis, normal=z_axis, type='radial') + result = surface_factory.disc(r=r, center=center, xaxis=x_axis, normal=z_axis, type="radial") else: - if not(np.allclose(np.diff(angles), pi/2, atol=1e-10)): - raise RuntimeError('Unknown square parametrization of disc elementary surface') - result = surface_factory.disc(r=r, center=center, xaxis=x_axis, normal=z_axis, type='square') + if not (np.allclose(np.diff(angles), pi / 2, atol=1e-10)): + raise RuntimeError("Unknown square parametrization of disc elementary surface") + result = surface_factory.disc(r=r, center=center, xaxis=x_axis, normal=z_axis, type="square") result.reparam(param_u, param_v) if swap: result.swap() return result - def plane(self): - dim = int( self.read_next_non_whitespace().strip()) - center = np.array(next(self.fstream).split(), dtype=float) - normal = np.array(next(self.fstream).split(), dtype=float) - x_axis = np.array(next(self.fstream).split(), dtype=float) - finite = next(self.fstream).strip() != '0' + def plane(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + center = np.array(next(self.fstream).split(), dtype=float) + normal = np.array(next(self.fstream).split(), dtype=float) + x_axis = np.array(next(self.fstream).split(), dtype=float) + finite = next(self.fstream).strip() != "0" if finite: - param_u= np.array(next(self.fstream).split(), dtype=float) - param_v= np.array(next(self.fstream).split(), dtype=float) + param_u = np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) else: - param_u= [-state.unlimited, +state.unlimited] - param_v= [-state.unlimited, +state.unlimited] - swap = next(self.fstream).strip() != '0' + param_u = np.array([-state.unlimited, +state.unlimited]) + param_v = np.array([-state.unlimited, +state.unlimited]) + swap = next(self.fstream).strip() != "0" - result = Surface() * [param_u[1]-param_u[0], param_v[1]-param_v[0]] + [param_u[0],param_v[0]] + result = Surface() * [param_u[1] - param_u[0], param_v[1] - param_v[0]] + [param_u[0], param_v[0]] result.rotate(rotate_local_x_axis(x_axis, normal)) - result = flip_and_move_plane_geometry(result,center,normal) + result = result.flip_and_move_plane_geometry(center, normal) result.reparam(param_u, param_v) - if(swap): + if swap: result.swap() return result - def torus(self): - dim = int( self.read_next_non_whitespace().strip()) - r2 = float( next(self.fstream).strip()) - r1 = float( next(self.fstream).strip()) - center = np.array(next(self.fstream).split(), dtype=float) - z_axis = np.array(next(self.fstream).split(), dtype=float) - x_axis = np.array(next(self.fstream).split(), dtype=float) - select_out= next(self.fstream).strip() != '0' # I have no idea what this does :( - param_u = np.array(next(self.fstream).split(), dtype=float) - param_v = np.array(next(self.fstream).split(), dtype=float) - swap = next(self.fstream).strip() != '0' + def torus(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + r2 = float(next(self.fstream).strip()) + r1 = float(next(self.fstream).strip()) + center = np.array(next(self.fstream).split(), dtype=float) + z_axis = np.array(next(self.fstream).split(), dtype=float) + x_axis = np.array(next(self.fstream).split(), dtype=float) + next(self.fstream) + # select_out= next(self.fstream).strip() != '0' # I have no idea what this does :( + param_u = np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) + swap = next(self.fstream).strip() != "0" result = surface_factory.torus(minor_r=r1, major_r=r2, center=center, normal=z_axis, xaxis=x_axis) result.reparam(param_u, param_v) - if(swap): + if swap: result.swap() return result - def sphere(self): - dim = int( self.read_next_non_whitespace().strip()) - r = float( next(self.fstream).strip()) - center = np.array(next(self.fstream).split(), dtype=float) - z_axis = np.array(next(self.fstream).split(), dtype=float) - x_axis = np.array(next(self.fstream).split(), dtype=float) - param_u = np.array(next(self.fstream).split(), dtype=float) - param_v = np.array(next(self.fstream).split(), dtype=float) - swap = next(self.fstream).strip() != '0' + def sphere(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + r = float(next(self.fstream).strip()) + center = np.array(next(self.fstream).split(), dtype=float) + z_axis = np.array(next(self.fstream).split(), dtype=float) + x_axis = np.array(next(self.fstream).split(), dtype=float) + param_u = np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) + swap = next(self.fstream).strip() != "0" result = surface_factory.sphere(r=r, center=center, xaxis=x_axis, zaxis=z_axis).swap() if swap: @@ -187,77 +299,71 @@ def sphere(self): result.reparam(param_u, param_v) return result - def splines(self, pardim): - cls = G2.classes[pardim-1] - + def splines(self, pardim: int) -> SplineObject: _, rational = self.read_next_non_whitespace().strip().split() - rational = bool(int(rational)) + rational_bool = bool(int(rational)) bases = [self.read_basis() for _ in range(pardim)] ncps = 1 for b in bases: ncps *= b.num_functions() - cps = [tuple(map(float, next(self.fstream).split())) - for _ in range(ncps)] + cps = [tuple(map(float, next(self.fstream).split())) for _ in range(ncps)] - args = bases + [cps, rational] - return cls(*args) + return SplineObject.constructor(pardim)(bases, cps, rational=rational_bool) - def surface_of_linear_extrusion(self): - dim = int( self.read_next_non_whitespace().strip()) - crv = self.splines(1) - normal = np.array(self.read_next_non_whitespace().split(), dtype=float) - finite = next(self.fstream).strip() != '0' - param_u = np.array(next(self.fstream).split(), dtype=float) + def surface_of_linear_extrusion(self) -> Surface: + self.read_next_non_whitespace() + # dim = int( self.read_next_non_whitespace().strip()) + crv = self.splines(1) + normal = np.array(self.read_next_non_whitespace().split(), dtype=float) + finite = next(self.fstream).strip() != "0" + param_u = np.array(next(self.fstream).split(), dtype=float) if finite: - param_v=np.array(next(self.fstream).split(), dtype=float) + param_v = np.array(next(self.fstream).split(), dtype=float) else: - param_v=[-state.unlimited, +state.unlimited] - swap = next(self.fstream).strip() != '0' + param_v = np.array([-state.unlimited, +state.unlimited]) + swap = next(self.fstream).strip() != "0" - result = surface_factory.extrude(crv + normal*param_v[0], normal*(param_v[1]-param_v[0])) + result = surface_factory.extrude(crv + normal * param_v[0], normal * (param_v[1] - param_v[0])) result.reparam(param_u, param_v) if swap: result.swap() return result - - def bounded_surface(self): - objtype = int( next(self.fstream).strip() ) + def bounded_surface(self) -> TrimmedSurface: + objtype = int(next(self.fstream).strip()) # create the underlying surface which all trimming curves are to be applied if objtype in G2.g2_generators: - constructor = getattr(self, G2.g2_generators[objtype].__name__) + constructor = getattr(self, G2.g2_generators[objtype]) surface = constructor() elif objtype == 200: surface = self.splines(2) else: - raise IOError('Unsopported trimmed surface or malformed input file') + raise OSError("Unsopported trimmed surface or malformed input file") # for all trimming loops - numb_loops = int( self.read_next_non_whitespace() ) - all_loops = [] + numb_loops = int(self.read_next_non_whitespace()) + all_loops = [] for i in range(numb_loops): - # for all cuve pieces of that loop numb_crvs, space_epsilon = next(self.fstream).split() state.parametric_absolute_tolerance = float(space_epsilon) one_loop = [] for j in range(int(numb_crvs)): - # read a physical and parametric representation of the same curve _, parameter_curve_type, space_curve_type = map(int, self.read_next_non_whitespace().split()) two_curves = [] for crv_type in [parameter_curve_type, space_curve_type]: if crv_type in G2.g2_generators: - constructor = getattr(self, G2.g2_generators[crv_type].__name__) + constructor = getattr(self, G2.g2_generators[crv_type]) crv = constructor() elif crv_type == 100: crv = self.splines(1) else: - raise IOError('Unsopported trimming curve or malformed input file') + raise OSError("Unsopported trimming curve or malformed input file") two_curves.append(crv) # only keep the parametric version (re-generate physical one if we need it) @@ -265,90 +371,6 @@ def bounded_surface(self): self.trimming_curves.append(two_curves[1]) all_loops.append(one_loop) - return TrimmedSurface(surface.bases[0], surface.bases[1], surface.controlpoints, surface.rational, all_loops, raw=True) - - g2_type = [100, 200, 700] # curve, surface, volume identifiers - classes = [Curve, Surface, Volume] - g2_generators = {120:line, 130:circle, 140:ellipse, - 260:cylinder, 292:disc, 270:sphere, 290:torus, 250:plane, - 210:bounded_surface, 261:surface_of_linear_extrusion} #, 280:cone - - def __init__(self, filename): - if filename[-3:] != '.g2': - filename += '.g2' - self.filename = filename - self.trimming_curves = [] - - def __enter__(self): - return self - - def write(self, obj): - if not hasattr(self, 'fstream'): - self.onlywrite = True - self.fstream = open(self.filename, 'w') - if not self.onlywrite: - raise IOError('Could not write to file %s' % (self.filename)) - - """Write the object in GoTools format. """ - if isinstance(obj[0], SplineObject): # input SplineModel or list - for o in obj: - self.write(o) - return - - for i in range(obj.pardim): - if obj.periodic(i): - obj = obj.split(obj.start(i), i) - - self.fstream.write('{} 1 0 0\n'.format(G2.g2_type[obj.pardim-1])) - self.fstream.write('{} {}\n'.format(obj.dimension, int(obj.rational))) - for b in obj.bases: - self.fstream.write('%i %i\n' % (len(b.knots) - b.order, b.order)) - self.fstream.write(' '.join('%.16g' % k for k in b.knots)) - self.fstream.write('\n') - - savetxt(self.fstream, obj.controlpoints.reshape(-1, obj.dimension + obj.rational, order='F'), - fmt='%.16g', delimiter=' ', newline='\n') - - def read(self): - if not hasattr(self, 'fstream'): - self.onlywrite = False - self.fstream = open(self.filename, 'r') - - if self.onlywrite: - raise IOError('Could not read from file %s' % (self.filename)) - - result = [] - - for line in self.fstream: - line = line.strip() - if not line: - continue - - # read object type - objtype, major, minor, patch = map(int, line.split()) - if (major, minor, patch) != (1, 0, 0): - raise IOError('Unknown G2 format') - - # if obj type is in factory methods (cicle, torus etc), create it now - if objtype in G2.g2_generators: - constructor = getattr(self, G2.g2_generators[objtype].__name__) - result.append( constructor() ) - continue - - # for "normal" splines (Curves, Surfaces, Volumes) create it now - pardim = [i for i in range(len(G2.g2_type)) if G2.g2_type[i] == objtype] - if not pardim: - raise IOError('Unknown G2 object type {}'.format(objtype)) - pardim = pardim[0] + 1 - result.append(self.splines(pardim)) - - return result - - def read_basis(self): - ncps, order = map(int, next(self.fstream).split()) - kts = list(map(float, next(self.fstream).split())) - return BSplineBasis(order, kts, -1) - - def __exit__(self, exc_type, exc_value, traceback): - if hasattr(self, 'fstream'): - self.fstream.close() + return TrimmedSurface( + surface.bases[0], surface.bases[1], surface.controlpoints, surface.rational, all_loops, raw=True + ) diff --git a/splipy/io/grdecl.py b/splipy/io/grdecl.py index f509123..40e2d1a 100644 --- a/splipy/io/grdecl.py +++ b/splipy/io/grdecl.py @@ -1,37 +1,42 @@ -from itertools import product, chain +from __future__ import annotations + import re import warnings +from itertools import chain, product +from pathlib import Path +from typing import TYPE_CHECKING, Sequence, Union -import numpy as np -from tqdm import tqdm import cv2 import h5py -from scipy.spatial import Delaunay -from scipy.spatial.qhull import QhullError +import numpy as np +from scipy.spatial import Delaunay, QhullError +from tqdm import tqdm -from ..surface import Surface -from ..volume import Volume -from ..splineobject import SplineObject -from ..basis import BSplineBasis -from ..utils import ensure_listlike -from .. import surface_factory, curve_factory, volume_factory +from splipy import curve_factory, surface_factory, volume_factory +from splipy.basis import BSplineBasis +from splipy.utils import ensure_listlike +from splipy.volume import Volume -from .master import MasterIO from .g2 import G2 +from .master import MasterIO +if TYPE_CHECKING: + from typing import TextIO -class Box(object): + from splipy.splinemodel import SplineModel + from splipy.splineobject import SplineObject + +class Box: def __init__(self, x): self.x = x -class DiscontBoxMesh(object): - +class DiscontBoxMesh: def __init__(self, n, coord, zcorn): nx, ny, nz = n - X = np.empty(n + 1, dtype=object) + X = np.empty(n + 1, dtype=object) Xz = np.zeros((nx + 1, ny + 1, 2 * nz, 3)) cells = np.empty(n, dtype=object) @@ -39,19 +44,19 @@ def __init__(self, n, coord, zcorn): x = [] for k0, j0, i0 in product(range(2), repeat=3): # Interpolate to find the x,y values of this point - zmin, zmax = coord[i+i0, j+j0, :, 2] - z = zcorn[2*i+i0, 2*j+j0, 2*k+k0] + zmin, zmax = coord[i + i0, j + j0, :, 2] + z = zcorn[2 * i + i0, 2 * j + j0, 2 * k + k0] t = (z - zmax) / (zmin - zmax) - point = coord[i+i0, j+j0, 0] * t + coord[i+i0, j+j0, 1] * (1 - t) + point = coord[i + i0, j + j0, 0] * t + coord[i + i0, j + j0, 1] * (1 - t) x.append(point) - if X[i+i0,j+j0,k+k0] is None: - X[i+i0,j+j0,k+k0] = [point] + if X[i + i0, j + j0, k + k0] is None: + X[i + i0, j + j0, k + k0] = [point] else: - X[i+i0,j+j0,k+k0].append(point) - Xz[i+i0,j+j0,2*k+k0,:] = point + X[i + i0, j + j0, k + k0].append(point) + Xz[i + i0, j + j0, 2 * k + k0, :] = point - cells[i,j,k] = Box(x) + cells[i, j, k] = Box(x) self.X = X self.Xz = Xz @@ -63,15 +68,21 @@ def hull_or_none(x): except QhullError: return None - self.plane_hull = np.array([ - [Delaunay(np.reshape(coord[i:i+2, j:j+2, :, :], (8,3))) for j in range(ny)] - for i in range(nx) - ], dtype=object) - - self.hull = np.array([ - [[hull_or_none(cell.x) for cell in cell_tower] for cell_tower in cells_tmp] - for cells_tmp in cells - ], dtype=object) + self.plane_hull = np.array( + [ + [Delaunay(np.reshape(coord[i : i + 2, j : j + 2, :, :], (8, 3))) for j in range(ny)] + for i in range(nx) + ], + dtype=object, + ) + + self.hull = np.array( + [ + [[hull_or_none(cell.x) for cell in cell_tower] for cell_tower in cells_tmp] + for cells_tmp in cells + ], + dtype=object, + ) def cell_at(self, x, guess=None): # First, find the 'tower' containing x @@ -80,10 +91,10 @@ def cell_at(self, x, guess=None): numb_hits = [] if guess is not None: i, j, _ = guess - check = self.plane_hull[i,j].find_simplex(x) + check = self.plane_hull[i, j].find_simplex(x) # if check > -1: print('correct tower!') if check >= 0: - numb_hits += [(i,j)] + numb_hits += [(i, j)] last_i = i last_j = j check = -1 @@ -91,16 +102,16 @@ def cell_at(self, x, guess=None): for (i, j), hull in np.ndenumerate(self.plane_hull): check = hull.find_simplex(x) if check >= 0: - numb_hits += [(i,j)] + numb_hits += [(i, j)] last_i = i last_j = j - i,j = last_i,last_j + i, j = last_i, last_j -# if len(numb_hits) != 1: -# print(numb_hits) -# print(x) -# print(guess) -# print(check) + # if len(numb_hits) != 1: + # print(numb_hits) + # print(x) + # print(guess) + # print(check) # assert check >= 0 assert len(numb_hits) >= 1 @@ -109,15 +120,18 @@ def cell_at(self, x, guess=None): check = -1 if guess is not None: _, _, k = guess - check = self.hull[i,j,k].find_simplex(x) + check = self.hull[i, j, k].find_simplex(x) # if check > -1: print('correct cell!') if check == -1: - for (i,j) in numb_hits: - for k, hull in enumerate(self.hull[i,j,:]): - if hull is None: continue + for i, j in numb_hits: + for k, hull in enumerate(self.hull[i, j, :]): + if hull is None: + continue check = hull.find_simplex(x) - if check >= 0: break - if check >= 0: break + if check >= 0: + break + if check >= 0: + break if check < 0: print(numb_hits) @@ -133,7 +147,7 @@ def get_c0_avg(self): """Compute best-approximation vertices for a continuous mesh by averaging the location of all corners that 'should' coincide. """ - return np.array([[[np.mean(k,axis=0) for k in j] for j in i] for i in self.X]) + return np.array([[[np.mean(k, axis=0) for k in j] for j in i] for i in self.X]) def get_discontinuous_all(self): """Return a list of vertices suitable for a fully discontinuous mesh.""" @@ -145,15 +159,15 @@ def get_discontinuous_z(self): class GRDECL(MasterIO): + filename: Path + fstream: TextIO def __init__(self, filename): - if not filename.endswith('.grdecl'): - filename += '.grdecl' - self.filename = filename + self.filename = Path(filename) self.attribute = {} def __enter__(self): - self.fstream = open(self.filename, 'r') + self.fstream = self.filename.open("r").__enter__() self.line_number = 0 return self @@ -164,99 +178,113 @@ def read_specgrid(self): def read_coord(self): nx, ny = self.n[:2] ans = np.zeros((nx + 1, ny + 1, 2, 3)) - for j, i in product(range(ny+1), range(nx+1)): + for j, i in product(range(ny + 1), range(nx + 1)): args = next(self.fstream).split() - ans[i,j,0,:] = np.array(args[:3], dtype=np.float64) - ans[i,j,1,:] = np.array(args[3:], dtype=np.float64) + ans[i, j, 0, :] = np.array(args[:3], dtype=np.float64) + ans[i, j, 1, :] = np.array(args[3:], dtype=np.float64) return ans def read_zcorn(self): - ntot = np.prod(self.n)*8 + ntot = np.prod(self.n) * 8 numbers = [] while len(numbers) < ntot: numbers += next(self.fstream).split() - numbers = numbers[:ntot] # strip away any '/' characters at the end of the line - return np.reshape(np.array(numbers, dtype=np.float64), self.n*2, order='F') + numbers = numbers[:ntot] # strip away any '/' characters at the end of the line + return np.reshape(np.array(numbers, dtype=np.float64), self.n * 2, order="F") def cell_property(self, dtype=np.float64): ntot = np.prod(self.n) numbers = [] while len(numbers) < ntot: numbers += next(self.fstream).split() - numbers = numbers[:ntot] # strip away any '/' characters at the end of the line + numbers = numbers[:ntot] # strip away any '/' characters at the end of the line return np.array(numbers, dtype=dtype) def read(self): for line in self.fstream: line = line.strip().lower() - if line == 'specgrid': + if line == "specgrid": self.n = self.read_specgrid() - elif line == 'coord': + elif line == "coord": self.coord = self.read_coord() - elif line == 'zcorn': + elif line == "zcorn": self.zcorn = self.read_zcorn() - elif line in {'actnum', 'permx', 'permy', 'permz', 'poro', 'satnum', 'rho', 'kx', 'kz', 'emodulus25', 'poissonratio25', 'pressure', }: - dtype = np.int32 if line in {'actnum', 'satnum'} else np.float64 - self.attribute[line] = self.cell_property(dtype) - elif line in {'grid', '/', ''} or line.startswith('--'): + elif line in { + "actnum", + "permx", + "permy", + "permz", + "poro", + "satnum", + "rho", + "kx", + "kz", + "emodulus25", + "poissonratio25", + "pressure", + }: + dtype = np.int32 if line in {"actnum", "satnum"} else np.float64 + self.attribute[line] = np.array(self.cell_property(), dtype=dtype) + elif line in {"grid", "/", ""} or line.startswith("--"): pass - elif not re.match('[0-9]', line[0]): + elif not re.match("[0-9]", line[0]): warnings.showwarning( - 'Unkown keyword "{}" encountered in file'.format(line.split()[0]), - SyntaxWarning, self.filename, self.line_number, line=[], + f'Unkown keyword "{line.split()[0]}" encountered in file', + SyntaxWarning, + str(self.filename), + self.line_number, + line=[], ) else: - pass # silently skip large number blocks + pass # silently skip large number blocks self.raw = DiscontBoxMesh(self.n, self.coord, self.zcorn) + def write(self, obj: Union[SplineObject, SplineModel, Sequence[SplineObject]]) -> None: + raise OSError("Writing to GRDECL not supported") def get_c0_mesh(self): # Create the C0-mesh nx, ny, nz = self.n X = self.raw.get_c0_avg() - b1 = BSplineBasis(2, [0] + [i/nx for i in range(nx+1)] + [1]) - b2 = BSplineBasis(2, [0] + [i/ny for i in range(ny+1)] + [1]) - b3 = BSplineBasis(2, [0] + [i/nz for i in range(nz+1)] + [1]) - c0_vol = volume_factory.interpolate(X, [b1, b2, b3]) - return c0_vol + b1 = BSplineBasis(2, [0] + [i / nx for i in range(nx + 1)] + [1]) + b2 = BSplineBasis(2, [0] + [i / ny for i in range(ny + 1)] + [1]) + b3 = BSplineBasis(2, [0] + [i / nz for i in range(nz + 1)] + [1]) + return volume_factory.interpolate(X, [b1, b2, b3]) def get_cm1_mesh(self): # Create the C^{-1} mesh nx, ny, nz = self.n Xm1 = self.raw.get_discontinuous_all() - b1 = BSplineBasis(2, sorted(list(range(self.n[0]+1))*2)) - b2 = BSplineBasis(2, sorted(list(range(self.n[1]+1))*2)) - b3 = BSplineBasis(2, sorted(list(range(self.n[2]+1))*2)) - discont_vol = Volume(b1, b2, b3, Xm1) - return discont_vol + b1 = BSplineBasis(2, sorted(list(range(self.n[0] + 1)) * 2)) + b2 = BSplineBasis(2, sorted(list(range(self.n[1] + 1)) * 2)) + b3 = BSplineBasis(2, sorted(list(range(self.n[2] + 1)) * 2)) + return Volume(b1, b2, b3, Xm1) def get_mixed_cont_mesh(self): # Create mixed discontinuity mesh: C^0, C^0, C^{-1} nx, ny, nz = self.n Xz = self.raw.get_discontinuous_z() - b1 = BSplineBasis(2, sorted(list(range(self.n[0]+1))+[0,self.n[0]])) - b2 = BSplineBasis(2, sorted(list(range(self.n[1]+1))+[0,self.n[1]])) - b3 = BSplineBasis(2, sorted(list(range(self.n[2]+1))*2)) - true_vol = Volume(b1, b2, b3, Xz, raw=True) - return true_vol + b1 = BSplineBasis(2, sorted(list(range(self.n[0] + 1)) + [0, self.n[0]])) + b2 = BSplineBasis(2, sorted(list(range(self.n[1] + 1)) + [0, self.n[1]])) + b3 = BSplineBasis(2, sorted(list(range(self.n[2] + 1)) * 2)) + return Volume(b1, b2, b3, Xz, raw=True) - def texture(self, p, ngeom, ntexture, method='full', irange=[None,None], jrange=[None,None]): + def texture(self, p, ngeom, ntexture, method="full", irange=[None, None], jrange=[None, None]): # Set the dimensions of geometry and texture map # ngeom = np.floor(self.n / (p-1)) # ntexture = np.floor(self.n * n) # ngeom = ngeom.astype(np.int32) # ntexture = ntexture.astype(np.int32) - ngeom = ensure_listlike(ngeom, 3) + ngeom = ensure_listlike(ngeom, 3) ntexture = ensure_listlike(ntexture, 3) - p = ensure_listlike(p, 3) - + p = ensure_listlike(p, 3) # Create the geometry ngx, ngy, ngz = ngeom - b1 = BSplineBasis(p[0], [0]*(p[0]-1) + [i/ngx for i in range(ngx+1)] + [1]*(p[0]-1)) - b2 = BSplineBasis(p[1], [0]*(p[1]-1) + [i/ngy for i in range(ngy+1)] + [1]*(p[1]-1)) - b3 = BSplineBasis(p[2], [0]*(p[2]-1) + [i/ngz for i in range(ngz+1)] + [1]*(p[2]-1)) + b1 = BSplineBasis(p[0], [0] * (p[0] - 1) + [i / ngx for i in range(ngx + 1)] + [1] * (p[0] - 1)) + b2 = BSplineBasis(p[1], [0] * (p[1] - 1) + [i / ngy for i in range(ngy + 1)] + [1] * (p[1] - 1)) + b3 = BSplineBasis(p[2], [0] * (p[2] - 1) + [i / ngz for i in range(ngz + 1)] + [1] * (p[2] - 1)) l2_fit = surface_factory.least_square_fit vol = self.get_c0_mesh() @@ -264,10 +292,14 @@ def texture(self, p, ngeom, ntexture, method='full', irange=[None,None], jrange= i = slice(irange[0], irange[1], None) j = slice(jrange[0], jrange[1], None) # special case number of evaluation points for full domain - if irange[1] == None: irange[1] = vol.shape[0] - if jrange[1] == None: jrange[1] = vol.shape[1] - if irange[0] == None: irange[0] = 0 - if jrange[0] == None: jrange[0] = 0 + if irange[1] is None: + irange[1] = vol.shape[0] + if jrange[1] is None: + jrange[1] = vol.shape[1] + if irange[0] is None: + irange[0] = 0 + if jrange[0] is None: + jrange[0] = 0 nu = np.diff(irange) nv = np.diff(jrange) @@ -278,52 +310,50 @@ def texture(self, p, ngeom, ntexture, method='full', irange=[None,None], jrange= w = np.linspace(0, 1, nw) crvs = [] - crvs.append(curve_factory.polygon(vol[i ,jrange[0] , 0,:].squeeze())) - crvs.append(curve_factory.polygon(vol[i ,jrange[0] ,-1,:].squeeze())) - crvs.append(curve_factory.polygon(vol[i ,jrange[1]-1, 0,:].squeeze())) - crvs.append(curve_factory.polygon(vol[i ,jrange[1]-1,-1,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[0] ,j , 0,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[0] ,j ,-1,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[1]-1,j , 0,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[1]-1,j ,-1,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[0] ,jrange[0] , :,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[0] ,jrange[1]-1, :,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[1]-1,jrange[0] , :,:].squeeze())) - crvs.append(curve_factory.polygon(vol[irange[1]-1,jrange[1]-1, :,:].squeeze())) -# with G2('curves.g2') as myfile: -# myfile.write(crvs) -# print('Written curve.g2') - - - if method == 'full': - bottom = l2_fit(vol[i, j, 0,:].squeeze(), [b1, b2], [u, v]) - top = l2_fit(vol[i, j, -1,:].squeeze(), [b1, b2], [u, v]) - left = l2_fit(vol[irange[0] ,j, :,:].squeeze(), [b2, b3], [v, w]) - right = l2_fit(vol[irange[1]-1,j, :,:].squeeze(), [b2, b3], [v, w]) - front = l2_fit(vol[i, jrange[0], :,:].squeeze(), [b1, b3], [u, w]) - back = l2_fit(vol[i, jrange[1]-1,:,:].squeeze(), [b1, b3], [u, w]) + crvs.append(curve_factory.polygon(vol[i, jrange[0], 0, :].squeeze())) + crvs.append(curve_factory.polygon(vol[i, jrange[0], -1, :].squeeze())) + crvs.append(curve_factory.polygon(vol[i, jrange[1] - 1, 0, :].squeeze())) + crvs.append(curve_factory.polygon(vol[i, jrange[1] - 1, -1, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[0], j, 0, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[0], j, -1, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[1] - 1, j, 0, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[1] - 1, j, -1, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[0], jrange[0], :, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[0], jrange[1] - 1, :, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[1] - 1, jrange[0], :, :].squeeze())) + crvs.append(curve_factory.polygon(vol[irange[1] - 1, jrange[1] - 1, :, :].squeeze())) + # with G2('curves.g2') as myfile: + # myfile.write(crvs) + # print('Written curve.g2') + + if method == "full": + bottom = l2_fit(vol[i, j, 0, :].squeeze(), [b1, b2], [u, v]) + top = l2_fit(vol[i, j, -1, :].squeeze(), [b1, b2], [u, v]) + left = l2_fit(vol[irange[0], j, :, :].squeeze(), [b2, b3], [v, w]) + right = l2_fit(vol[irange[1] - 1, j, :, :].squeeze(), [b2, b3], [v, w]) + front = l2_fit(vol[i, jrange[0], :, :].squeeze(), [b1, b3], [u, w]) + back = l2_fit(vol[i, jrange[1] - 1, :, :].squeeze(), [b1, b3], [u, w]) volume = volume_factory.edge_surfaces([left, right, front, back, bottom, top]) - elif method == 'z': - bottom = l2_fit(vol[i,j, 0,:].squeeze(), [b1, b2], [u, v]) - top = l2_fit(vol[i,j,-1,:].squeeze(), [b1, b2], [u, v]) + elif method == "z": + bottom = l2_fit(vol[i, j, 0, :].squeeze(), [b1, b2], [u, v]) + top = l2_fit(vol[i, j, -1, :].squeeze(), [b1, b2], [u, v]) volume = volume_factory.edge_surfaces([bottom, top]) volume.set_order(*p) - volume.refine(ngz - 1, direction='w') + volume.refine(ngz - 1, direction="w") volume.reverse(direction=2) # Point-to-cell mapping - # TODO: Optimize more + # TODO(Kjetil): Optimize more eps = 1e-2 - u = [np.linspace(eps, 1-eps, n) for n in ntexture] + u = [np.linspace(eps, 1 - eps, n) for n in ntexture] points = volume(*u).reshape(-1, 3) cellids = np.zeros(points.shape[:-1], dtype=int) - cell = None nx, ny, nz = self.n - for ptid, point in enumerate(tqdm(points, desc='Inverse mapping')): - i, j, k = cell = self.raw.cell_at(point) # , guess=cell) - cellid = i*ny*nz + j*nz + k + for ptid, point in enumerate(tqdm(points, desc="Inverse mapping")): + i, j, k = self.raw.cell_at(point) + cellid = i * ny * nz + j * nz + k cellids[ptid] = cellid cellids = cellids.reshape(tuple(ntexture)) @@ -332,55 +362,61 @@ def texture(self, p, ngeom, ntexture, method='full', irange=[None,None], jrange= for name in self.attribute: data = self.attribute[name][cellids] - # TODO: This flattens the image if it happens to be 3D (or higher...) + # TODO(Kjetil): This flattens the image if it happens to be 3D (or higher...) # do we need a way to communicate the structure back to the caller? # data = data.reshape(-1, data.shape[-1]) - # TODO: This normalizes the image, + # TODO(Kjetil): This normalizes the image, # but we need a way to communicate the ranges back to the caller # a, b = min(data.flat), max(data.flat) # data = ((data - a) / (b - a) * 255).astype(np.uint8) all_textures[name] = data - all_textures['cellids'] = cellids + all_textures["cellids"] = cellids return volume, all_textures - def to_ifem(self, p, ngeom, ntexture, method='full', irange=[None,None], jrange=[None,None]): + def to_ifem(self, p, ngeom, ntexture, method="full", irange=[None, None], jrange=[None, None]): translate = { - 'emodulus25' : 'stiffness', - 'kx' : 'permx', - 'ky' : 'permy', - 'kz' : 'permz', - 'poissonratio25': 'poisson'} - - h5_filename = 'textures.h5' - h5_file = h5py.File(h5_filename, 'w') + "emodulus25": "stiffness", + "kx": "permx", + "ky": "permy", + "kz": "permz", + "poissonratio25": "poisson", + } + + h5_filename = "textures.h5" + h5_file = h5py.File(h5_filename, "w") vol, textures = self.texture(p, ngeom, ntexture, method, irange, jrange) # augment dataset with missing information - if 'kx' in textures and not 'ky' in textures: - textures['ky'] = textures['kx'] + if "kx" in textures and "ky" not in textures: + textures["ky"] = textures["kx"] # print information to png-images and hdf5-files - print(r'') + print(r"") for name, data in textures.items(): # translate to more IFEM-friendly terminology - if name in translate: name = translate[name] + if name in translate: + name = translate[name] - h5_file.create_dataset(name, data=data, compression='gzip') + h5_file.create_dataset(name, data=data, compression="gzip") a, b = min(data.flat), max(data.flat) - img = ((data - a) / (b - a) * 255).astype(np.uint8) - n = data.shape - img = img.reshape(n[0], n[1]*n[2]) - print(' '.format(name, a,b, name, n[0], n[1], n[2])) - cv2.imwrite(name+'.png', img) - print(r'') + img = ((data - a) / (b - a) * 255).astype(np.uint8) + n = data.shape + img = img.reshape(n[0], n[1] * n[2]) + print( + ' '.format( + name, a, b, name, n[0], n[1], n[2] + ) + ) + cv2.imwrite(name + ".png", img) + print(r"") h5_file.close() - print('Written {}'.format(h5_filename)) + print(f"Written {h5_filename}") - with G2('geom.g2') as myfile: + with G2("geom.g2") as myfile: myfile.write(vol) def __exit__(self, exc_type, exc_value, traceback): - self.fstream.close() + self.fstream.__exit__(exc_type, exc_value, traceback) diff --git a/splipy/io/master.py b/splipy/io/master.py index 58e16c8..0fd9fab 100644 --- a/splipy/io/master.py +++ b/splipy/io/master.py @@ -1,27 +1,51 @@ -class MasterIO(object): +from __future__ import annotations - def __init__(self, filename): +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Optional, Sequence, Union + +from typing_extensions import Self + +if TYPE_CHECKING: + from pathlib import Path + from types import TracebackType + + from splipy.splinemodel import SplineModel + from splipy.splineobject import SplineObject + + +class MasterIO(ABC): + def __init__(self, filename: Union[str, Path]) -> None: """Create an IO object attached to a file. :param str filename: The file to read from or write to """ raise NotImplementedError() - def __enter__(self): - raise NotImplementedError() + @abstractmethod + def __enter__(self) -> Self: + ... + + @abstractmethod + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + ... - def write(self, obj): + @abstractmethod + def write(self, obj: Union[SplineObject, Sequence[SplineObject], SplineModel]) -> None: """Write one or more objects to the file. :param obj: The object(s) to write :type obj: [:class:`splipy.SplineObject`] or :class:`splipy.SplineObject` """ - raise NotImplementedError() - def read(self): + @abstractmethod + def read(self) -> list[SplineObject]: """Reads all the objects from the file. :return: Objects :rtype: [:class:`splipy.SplineObject`] """ - raise NotImplementedError() diff --git a/splipy/io/ofoam.py b/splipy/io/ofoam.py index 5b45979..25b81c1 100644 --- a/splipy/io/ofoam.py +++ b/splipy/io/ofoam.py @@ -1,56 +1,73 @@ +from __future__ import annotations + from itertools import groupby from operator import itemgetter -from os.path import exists, isdir, join -from os import makedirs +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Sequence, Union import numpy as np +from typing_extensions import Self + +from splipy.splinemodel import SplineModel + +from .master import MasterIO -from ..splinemodel import SplineModel +if TYPE_CHECKING: + from types import TracebackType + from splipy.splineobject import SplineObject -class OpenFOAM(object): - def __init__(self, target): - self.target = target +class OpenFOAM(MasterIO): + target: Path - def __enter__(self): + def __init__(self, target: Union[Path, str]) -> None: + self.target = Path(target) + + def __enter__(self) -> Self: # Create the target directory if it does not exist - if not exists(self.target): - makedirs(target) + if not self.target.exists(): + self.target.mkdir(parents=True) + # If it does, ensure that it's a directory - elif not isdir(self.target): - raise FileExistsError('{} exists and is not a directory'.format(self.target)) + elif not self.target.is_dir(): + raise FileExistsError(f"{self.target} exists and is not a directory") return self - def __exit__(self, exc_type, exc_value, traceback): - pass - - def _header(self, cls, obj, note=None): - s = 'FoamFile\n{\n' - s += ' version 2.0;\n' - s += ' format ascii;\n' - s += ' class %s;\n' % cls + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + return + + def _header(self, cls: str, obj: str, note: Optional[str] = None) -> str: + s = "FoamFile\n{\n" + s += " version 2.0;\n" + s += " format ascii;\n" + s += f" class {cls};\n" if note: - s += ' note "%s";\n' % note - s += ' object %s;\n' % obj - s += '}\n' + s += f' note "{note}";\n' + s += f" object {obj};\n" + s += "}\n" return s - def write(self, model): + def write(self, model: Union[SplineObject, Sequence[SplineObject], SplineModel]) -> None: assert isinstance(model, SplineModel), "OpenFOAM.write only supports SplineModel objects" # Only linear volumes in 3D, please assert model.pardim == 3 assert model.dimension == 3 - assert all(patch.obj.order() == (2,2,2) for patch in model.catalogue.top_nodes()) + assert all(patch.obj.order() == (2, 2, 2) for patch in model.catalogue.top_nodes()) # Generate all the information we need model.generate_cp_numbers() model.generate_cell_numbers() faces = model.faces() - ninternal = sum(faces['name'] == None) - note = 'nPoints: {} nCells: {} nFaces: {} nInternalFaces: {}'.format( + ninternal = sum(faces["name"] == None) # noqa: E711 + note = "nPoints: {} nCells: {} nFaces: {} nInternalFaces: {}".format( model.ncps, model.ncells, len(faces), ninternal ) @@ -60,56 +77,57 @@ def write(self, model): # - All faces in the same boundary must be contiguous # - Low number owners before high number owners # - Low number neighbors before high number neighbors - faces = list(faces) - faces = sorted(faces, key=itemgetter('neighbor')) - faces = sorted(faces, key=itemgetter('owner')) - faces = sorted(faces, key=lambda x: (x['name'] is not None, x['name'])) - faces = np.array(faces) + faces_list = list(faces) + faces_list = sorted(faces_list, key=itemgetter("neighbor")) + faces_list = sorted(faces_list, key=itemgetter("owner")) + faces_list = sorted(faces_list, key=lambda x: (x["name"] is not None, x["name"])) + faces = np.array(faces_list) # Write the points file (vertex coordinates) - with open(join(self.target, 'points'), 'w') as f: - f.write(self._header('vectorField', 'points')) - f.write(str(model.ncps) + '\n(\n') + with (self.target / "points").open("w") as f: + f.write(self._header("vectorField", "points")) + f.write(str(model.ncps) + "\n(\n") for pt in model.cps(): - f.write('({})\n'.format(' '.join(str(p) for p in pt))) - f.write(')\n') + f.write("({})\n".format(" ".join(str(p) for p in pt))) + f.write(")\n") # Write the faces file (four vertex indices for each face) - with open(join(self.target, 'faces'), 'w') as f: - f.write(self._header('faceList', 'faces')) - f.write(str(len(faces)) + '\n(\n') - for face in faces['nodes']: - f.write('({})\n'.format(' '.join(str(f) for f in face))) - f.write(')\n') + with (self.target / "faces").open("w") as f: + f.write(self._header("faceList", "faces")) + f.write(str(len(faces)) + "\n(\n") + for face in faces["nodes"]: + f.write("({})\n".format(" ".join(str(f) for f in face))) + f.write(")\n") # Write the owner and neighbour files (cell indices for each face) - with open(join(self.target, 'owner'), 'w') as f: - f.write(self._header('labelList', 'owner', note=note)) - f.write(str(len(faces)) + '\n(\n') - for owner in faces['owner']: - f.write(str(owner) + '\n') - f.write(')\n') - with open(join(self.target, 'neighbour'), 'w') as f: - f.write(self._header('labelList', 'neighbour', note=note)) - f.write(str(len(faces)) + '\n(\n') - for neighbor in faces['neighbor']: - f.write(str(neighbor) + '\n') - f.write(')\n') + with (self.target / "owner").open("w") as f: + f.write(self._header("labelList", "owner", note=note)) + f.write(str(len(faces)) + "\n(\n") + for owner in faces["owner"]: + f.write(str(owner) + "\n") + f.write(")\n") + + with (self.target / "neighbour").open("w") as f: + f.write(self._header("labelList", "neighbour", note=note)) + f.write(str(len(faces)) + "\n(\n") + for neighbor in faces["neighbor"]: + f.write(str(neighbor) + "\n") + f.write(")\n") # Write the boundary file - with open(join(self.target, 'boundary'), 'w') as f: - f.write(self._header('polyBoundaryMesh', 'boundary')) - f.write(str(len(set(faces['name'])) - 1) + '\n(\n') + with (self.target / "boundary").open("w") as f: + f.write(self._header("polyBoundaryMesh", "boundary")) + f.write(str(len(set(faces["name"])) - 1) + "\n(\n") start = 0 - for name, it in groupby(faces, key=itemgetter('name')): + for name, it in groupby(faces, key=itemgetter("name")): nfaces = len(list(it)) if name is None: start += nfaces continue - f.write(name + '\n{\n') - f.write(' type patch;\n') - f.write(' nFaces {};\n'.format(nfaces)) - f.write(' startFace {};\n'.format(start)) - f.write('}\n') + f.write(name + "\n{\n") + f.write(" type patch;\n") + f.write(f" nFaces {nfaces};\n") + f.write(f" startFace {start};\n") + f.write("}\n") start += nfaces - f.write(')\n') + f.write(")\n") diff --git a/splipy/io/spl.py b/splipy/io/spl.py index 938cc8e..da18a67 100644 --- a/splipy/io/spl.py +++ b/splipy/io/spl.py @@ -1,38 +1,55 @@ +from __future__ import annotations + from itertools import islice +from pathlib import Path +from typing import TYPE_CHECKING, Iterator, Optional, Sequence, TextIO, Union import numpy as np +from typing_extensions import Self -from ..curve import Curve -from ..surface import Surface -from ..volume import Volume -from ..splineobject import SplineObject -from ..basis import BSplineBasis +from splipy.basis import BSplineBasis +from splipy.splineobject import SplineObject from .master import MasterIO +if TYPE_CHECKING: + from types import TracebackType + + from splipy.splinemodel import SplineModel + class SPL(MasterIO): + filename: Path + fstream: TextIO - def __init__(self, filename): - if not filename.endswith('.spl'): - filename += '.spl' - self.filename = filename - self.trimming_curves = [] + def __init__(self, filename: Union[Path, str]) -> None: + self.filename = Path(filename) - def __enter__(self): - self.fstream = open(self.filename, 'r') + def __enter__(self) -> Self: + self.fstream = self.filename.open("r").__enter__() return self - def lines(self): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.fstream.__exit__(exc_type, exc_val, exc_tb) + + def lines(self) -> Iterator[str]: for line in self.fstream: - yield line.split('#', maxsplit=1)[0].strip() + yield line.split("#", maxsplit=1)[0].strip() + + def write(self, obj: Union[SplineObject, SplineModel, Sequence[SplineObject]]) -> None: + raise OSError("Writing to SPL not supported") - def read(self): + def read(self) -> list[SplineObject]: lines = self.lines() version = next(lines).split() - assert version[0] == 'C' - assert version[3] == '0' # No support for rational SPL yet + assert version[0] == "C" + assert version[3] == "0" # No support for rational SPL yet pardim = int(version[1]) physdim = int(version[2]) @@ -41,24 +58,17 @@ def read(self): totcoeffs = int(np.prod(ncoeffs)) nknots = [a + b for a, b in zip(orders, ncoeffs)] - next(lines) # Skip spline accuracy + next(lines) # Skip spline accuracy knots = [[float(k) for k in islice(lines, nkts)] for nkts in nknots] bases = [BSplineBasis(p, kts, -1) for p, kts in zip(orders, knots)] - cpts = np.array([float(k) for k in islice(lines, totcoeffs * physdim)]) + cpts = np.array([float(k) for k in islice(lines, totcoeffs * physdim)], dtype=float) cpts = cpts.reshape(physdim, *(ncoeffs[::-1])).transpose() - if pardim == 1: - patch = Curve(*bases, controlpoints=cpts, raw=True) - elif pardim == 2: - patch = Surface(*bases, controlpoints=cpts, raw=True) - elif pardim == 3: - patch = Volume(*bases, controlpoints=cpts, raw=True) + if 1 <= pardim <= 3: + patch = SplineObject.constructor(pardim)(bases, cpts, raw=True) else: patch = SplineObject(bases, controlpoints=cpts, raw=True) return [patch] - - def __exit__(self, exc_type, exc_value, traceback): - self.fstream.close() diff --git a/splipy/io/stl.py b/splipy/io/stl.py index 3a54060..c24ca48 100644 --- a/splipy/io/stl.py +++ b/splipy/io/stl.py @@ -1,16 +1,26 @@ -#coding:utf-8 +from __future__ import annotations import struct +from abc import ABC, abstractmethod +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO, Optional, Sequence, TextIO, Union, cast import numpy as np +from typing_extensions import Self -from ..surface import Surface -from ..volume import Volume -from ..utils import ensure_listlike -from ..splinemodel import SplineModel +from splipy.splinemodel import SplineModel +from splipy.surface import Surface +from splipy.types import Scalars +from splipy.utils import ensure_listlike +from splipy.volume import Volume from .master import MasterIO +if TYPE_CHECKING: + from types import TracebackType + + from splipy.splineobject import SplineObject + ASCII_FACET = """facet normal 0 0 0 outer loop @@ -21,32 +31,32 @@ endfacet """ -BINARY_HEADER ="80sI" +BINARY_HEADER = "80sI" BINARY_FACET = "12fH" -class ASCII_STL_Writer(object): - """ Export 3D objects build of 3 or 4 vertices as ASCII STL file. - """ - def __init__(self, stream): - self.fp = stream - self._write_header() +Face = Sequence[Scalars] - def _write_header(self): - self.fp.write("solid python\n") - def close(self): - self.fp.write("endsolid python\n") +class StlWriter(ABC): + @abstractmethod + def _write_header(self) -> None: + ... - def _write(self, face): - self.fp.write(ASCII_FACET.format(face=face)) + @abstractmethod + def _write(self, face: Face) -> None: + ... - def _split(self, face): + @abstractmethod + def close(self) -> None: + ... + + def _split(self, face: Face) -> tuple[Face, Face]: p1, p2, p3, p4 = face return (p1, p2, p3), (p3, p4, p1) - def add_face(self, face): - """ Add one face with 3 or 4 vertices. """ + def add_face(self, face: Face) -> None: + """Add one face with 3 or 4 vertices.""" if len(face) == 4: face1, face2 = self._split(face) self._write(face1) @@ -54,120 +64,172 @@ def add_face(self, face): elif len(face) == 3: self._write(face) else: - raise ValueError('only 3 or 4 vertices for each face') + raise ValueError("only 3 or 4 vertices for each face") - def add_faces(self, faces): - """ Add many faces. """ + def add_faces(self, faces: Sequence[Face]) -> None: + """Add many faces.""" for face in faces: self.add_face(face) -class BINARY_STL_Writer(ASCII_STL_Writer): - """ Export 3D objects build of 3 or 4 vertices as binary STL file. - """ - def __init__(self, stream): - self.counter = 0 - #### new-style classes way of calling super constructor - # super(Binary_STL_Writer, self).__init__(stream) - #### old-style classes way of doing it - ASCII_STL_Writer.__init__(self, stream) +class AsciiStlWriter(StlWriter): + """Export 3D objects build of 3 or 4 vertices as ASCII STL file.""" + + fp: TextIO + + def __init__(self, stream: TextIO) -> None: + self.fp = stream + self._write_header() + + def _write_header(self) -> None: + self.fp.write("solid python\n") + + def close(self) -> None: + self.fp.write("endsolid python\n") + + def _write(self, face: Face) -> None: + self.fp.write(ASCII_FACET.format(face=face)) + + +class BinaryStlWriter(StlWriter): + """Export 3D objects build of 3 or 4 vertices as binary STL file.""" - def close(self): + counter: int + fp: BinaryIO + + def __init__(self, stream: BinaryIO) -> None: + self.counter = 0 + self.fp = stream + self._write_header() + + def close(self) -> None: self._write_header() - def _write_header(self): + def _write_header(self) -> None: self.fp.seek(0) - self.fp.write(struct.pack(BINARY_HEADER, b'Python Binary STL Writer', self.counter)) + self.fp.write(struct.pack(BINARY_HEADER, b"Python Binary STL Writer", self.counter)) - def _write(self, face): + def _write(self, face: Face) -> None: self.counter += 1 data = [ - 0., 0., 0., - face[0][0], face[0][1], face[0][2], - face[1][0], face[1][1], face[1][2], - face[2][0], face[2][1], face[2][2], - 0 + 0.0, + 0.0, + 0.0, + face[0][0], + face[0][1], + face[0][2], + face[1][0], + face[1][1], + face[1][2], + face[2][0], + face[2][1], + face[2][2], + 0, ] self.fp.write(struct.pack(BINARY_FACET, *data)) class STL(MasterIO): - def __init__(self, filename, binary=True): - if filename[-4:] != '.stl': - filename += '.stl' - self.filename = filename - self.binary = binary + filename: Path + binary: bool + + writer: Union[BinaryStlWriter, AsciiStlWriter] - def __enter__(self): + def __init__(self, filename: Union[str, Path], binary: bool = True) -> None: + self.filename = Path(filename) + self.binary = binary + + def __enter__(self) -> Self: if self.binary: - fp = open(self.filename, 'wb') - self.writer = BINARY_STL_Writer(fp) + self.writer = BinaryStlWriter(self.filename.open("wb")) else: - fp = open(self.filename, 'w') - self.writer = ASCII_STL_Writer(fp) + self.writer = AsciiStlWriter(self.filename.open("w")) return self - def write(self, obj, n=None): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + self.writer.close() + self.writer.fp.__exit__(exc_type, exc_val, exc_tb) + + def read(self) -> list[SplineObject]: + raise OSError("Reading STL not supported") + + def write( + self, + obj: Union[SplineModel, Sequence[SplineObject], SplineObject], + n: Optional[Union[int, Sequence[int]]] = None, + ) -> None: if isinstance(obj, SplineModel): - if obj.pardim == 3: # volume model - for surface in obj.boundary(): - self.write_surface(surface.obj,n) - elif obj.pardim == 2: # surface model + if obj.pardim == 3: # volume model + for node in obj.boundary(): + self.write_surface(cast(Surface, node.obj), n) + elif obj.pardim == 2: # surface model for surface in obj: - self.write_surface(surface, n) + self.write_surface(cast(Surface, surface), n) elif isinstance(obj, Volume): - for surface in obj.faces(): - if surface is not None: # happens with periodic volumes - self.write_surface(surface, n) + for face in obj.faces(): + if face is not None: # happens with periodic volumes + self.write_surface(face, n) elif isinstance(obj, Surface): self.write_surface(obj, n) + elif isinstance(obj, Sequence): + for o in obj: + self.write(o) + else: - raise ValueError('Unsopported object for STL format') + raise ValueError("Unsopported object for STL format") - def write_surface(self, surface, n=None): + def write_surface( + self, + surface: Surface, + n: Optional[Union[int, Sequence[int]]] = None, + ) -> None: # choose evaluation points as one of three cases: # 1. specified with input # 2. linear splines, only picks knots # 3. general splines choose 2*order-1 per knot span - if n != None: - n = ensure_listlike(n,2) + if n is not None: + n = ensure_listlike(n, 2) - if n != None: + if n is not None: u = np.linspace(surface.start(0), surface.end(0), n[0]) elif surface.order(0) == 2: u = surface.knots(0) else: knots = surface.knots(0) p = surface.order(0) - u = [np.linspace(k0,k1, 2*p-3, endpoint=False) for (k0,k1) in zip(knots[:-1], knots[1:])] - u = [point for element in u for point in element] + knots - u = np.sort(u) + ut = [np.linspace(k0, k1, 2 * p - 3, endpoint=False) for (k0, k1) in zip(knots[:-1], knots[1:])] + ut = [point for element in ut for point in element] + list(knots) + u = np.sort(ut) - if n != None: + if n is not None: v = np.linspace(surface.start(1), surface.end(1), n[1]) elif surface.order(1) == 2: v = surface.knots(1) else: knots = surface.knots(1) p = surface.order(1) - v = [np.linspace(k0,k1, 2*p-3, endpoint=False) for (k0,k1) in zip(knots[:-1], knots[1:])] - v = [point for element in v for point in element] + knots - v = np.sort(v) + vt = [np.linspace(k0, k1, 2 * p - 3, endpoint=False) for (k0, k1) in zip(knots[:-1], knots[1:])] + vt = [point for element in vt for point in element] + list(knots) + v = np.sort(vt) # perform evaluation and make sure that we have 3 components (in case of 2D geometries) - x = surface(u,v) + x = surface(u, v) if x.shape[2] != 3: - x.resize((x.shape[0],x.shape[1],3)) + x.resize((x.shape[0], x.shape[1], 3)) # compute tiny quad pieces - faces = [[x[i,j], x[i,j+1], x[i+1,j+1], x[i+1,j]] for i in range(x.shape[0]-1) for j in range(x.shape[1]-1)] + faces = [ + [x[i, j], x[i, j + 1], x[i + 1, j + 1], x[i + 1, j]] + for i in range(x.shape[0] - 1) + for j in range(x.shape[1] - 1) + ] self.writer.add_faces(faces) - - def __exit__(self, exc_type, exc_value, traceback): - self.writer.close() - self.writer.fp.close() - diff --git a/splipy/io/svg.py b/splipy/io/svg.py index 068feef..28456e7 100644 --- a/splipy/io/svg.py +++ b/splipy/io/svg.py @@ -1,90 +1,117 @@ +from __future__ import annotations + +import re import xml.etree.ElementTree as etree +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar, Optional, Sequence, Union, cast from xml.dom import minidom -import re import numpy as np +from typing_extensions import Self -from ..curve import Curve -from ..surface import Surface -from ..splineobject import SplineObject -from ..basis import BSplineBasis -from .. import curve_factory, state +from splipy import curve_factory, state +from splipy.basis import BSplineBasis +from splipy.curve import Curve +from splipy.splineobject import SplineObject +from splipy.surface import Surface from .master import MasterIO +if TYPE_CHECKING: + from types import TracebackType -def read_number_and_unit(mystring): - unit = '' + from splipy.splinemodel import SplineModel + from splipy.types import FArray + + +def read_number_and_unit(mystring: str) -> tuple[float, str]: + unit = "" try: - for i in range(1, len(mystring)+1): - number=float(mystring[:i]) + for i in range(1, len(mystring) + 1): + number = float(mystring[:i]) except ValueError: - unit=mystring[i-1:] + unit = mystring[i - 1 :] return (number, unit) -def bezier_representation(curve): - """ Compute a Bezier representation of a given spline curve. The input - curve must be of order less than or equal to 4. The bezier - representation is of order 4, and maximal knot multiplicity, i.e. - consist of a C0-discretization with knot multiplicity 3. +def bezier_representation(curve: Curve) -> Curve: + """Compute a Bezier representation of a given spline curve. The input + curve must be of order less than or equal to 4. The bezier + representation is of order 4, and maximal knot multiplicity, i.e. + consist of a C0-discretization with knot multiplicity 3. - :param curve : Spline curve - :type curve : Curve - :returns : Bezier curve - :rtype : Curve + :param curve : Spline curve + :type curve : Curve + :returns : Bezier curve + :rtype : Curve """ - # error test input. Another way would be to approximate higher-order curves. Consider looking at Curve.rebuild() + # Error test input. Another way would be to approximate higher-order curves. + # Consider looking at Curve.rebuild() if curve.order(0) > 4 or curve.rational: - raise RuntimeError('Bezier representation, only supported for non-rational curves of order 4 or less') + raise RuntimeError("Bezier representation, only supported for non-rational curves of order 4 or less") bezier = curve.clone() - bezier.raise_order(4-curve.order(0)) # make sure curve is cubic + bezier.raise_order(4 - curve.order(0)) # make sure curve is cubic # make it non-periodic if bezier.periodic(): - bezier = bezier.split(bezier.start(0)) + bezier = bezier.split_periodic(bezier.start(0)) # make sure it is C0 everywhere for k in bezier.knots(0): - bezier.insert_knot( [k]*bezier.continuity(k) ) + bezier.insert_knot([k] * bezier.continuity(k)) return bezier class SVG(MasterIO): - - namespace = '{http://www.w3.org/2000/svg}' - - def __init__(self, filename, width=1000, height=1000, margin=0.05): - """ Constructor - :param filename: Filename to write results to - :type filename: String - :param width : Maximum image width in pixels - :type width : Int - :param height : Maximum image height in pixels - :type height : Int - :param margin : White-space around all edges of image, given in percentage of total size (default 5%) - :type margin : Float + namespace: ClassVar[str] = "{http://www.w3.org/2000/svg}" + + filename: Path + width: float + height: float + margin: float + all_objects: list[SplineObject] + + scale: float + center: tuple[float, float] + offset: tuple[float, float] + xmlRoot: etree.Element + + def __init__( + self, filename: Union[Path, str], width: int = 1000, height: int = 1000, margin: float = 0.05 + ) -> None: + """Constructor + + :param filename: Filename to write results to + :type filename: String + :param width : Maximum image width in pixels + :type width : Int + :param height : Maximum image height in pixels + :type height : Int + :param margin : White-space around all edges of image, given in percentage of total size (default 5%) + :type margin : Float """ - if filename[-4:] != '.svg': - filename += '.svg' - self.filename = filename + self.filename = Path(filename) - self.width = width + self.width = width self.height = height self.margin = margin self.all_objects = [] - def __enter__(self): + def __enter__(self) -> Self: return self - def __exit__(self, exc_type, exc_value, traceback): + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: # in case something goes wrong, print error and abandon writing process if exc_type is not None: - print(exc_type, exc_value, traceback) - return False + print(exc_type, exc_val, exc_tb) # compute the bounding box for all geometries boundingbox = [np.inf, np.inf, -np.inf, -np.inf] @@ -95,25 +122,31 @@ def __exit__(self, exc_type, exc_value, traceback): boundingbox[2] = max(boundingbox[2], bb[0][1]) boundingbox[3] = max(boundingbox[3], bb[1][1]) - # compute scaling factors by keeping aspect ratio, and never exceed width or height size (including margins) - geometryRatio = float(boundingbox[3]-boundingbox[1])/(boundingbox[2]-boundingbox[0]) - imageRatio = 1.0*self.height / self.width - if geometryRatio > imageRatio: # scale by y-coordinate - marginPixels = self.height*self.margin - self.scale = self.height*(1-2*self.margin) / (boundingbox[3]-boundingbox[1]) - self.width = self.height/geometryRatio + 2*marginPixels - else: # scale by x-coordinate - marginPixels = self.width*self.margin - self.scale = self.width*(1-2*self.margin) / (boundingbox[2]-boundingbox[0]) - self.height = self.width*geometryRatio + 2*marginPixels - self.center = [boundingbox[0], boundingbox[1]] - self.offset = [marginPixels, marginPixels] + # Compute scaling factors by keeping aspect ratio, + # and never exceed width or height size (including margins) + geometryRatio = float(boundingbox[3] - boundingbox[1]) / (boundingbox[2] - boundingbox[0]) + imageRatio = 1.0 * self.height / self.width + if geometryRatio > imageRatio: # scale by y-coordinate + marginPixels = self.height * self.margin + self.scale = self.height * (1 - 2 * self.margin) / (boundingbox[3] - boundingbox[1]) + self.width = self.height / geometryRatio + 2 * marginPixels + else: # scale by x-coordinate + marginPixels = self.width * self.margin + self.scale = self.width * (1 - 2 * self.margin) / (boundingbox[2] - boundingbox[0]) + self.height = self.width * geometryRatio + 2 * marginPixels + self.center = (boundingbox[0], boundingbox[1]) + self.offset = (marginPixels, marginPixels) # create xml root tag - self.xmlRoot = etree.Element('svg', {'xmlns':'http://www.w3.org/2000/svg', - 'version':'1.1', - 'width':str(self.width), - 'height':str(self.height)}) + self.xmlRoot = etree.Element( + "svg", + { + "xmlns": "http://www.w3.org/2000/svg", + "version": "1.1", + "width": str(self.width), + "height": str(self.height), + }, + ) # populate tree with all curves and surfaces in entities for entry in self.all_objects: @@ -124,56 +157,66 @@ def __exit__(self, exc_type, exc_value, traceback): # if no objects are stored, then we've most likely only called read() if len(self.all_objects) > 0: - rough_string = etree.tostring(self.xmlRoot) # entire xml-file on one line - reparsed = minidom.parseString(rough_string) - result = reparsed.toprettyxml(indent=" ") # adds newline and inline - f = open(self.filename, 'w') + rough_string = etree.tostring(self.xmlRoot) # entire xml-file on one line + reparsed = minidom.parseString(rough_string) + result = reparsed.toprettyxml(indent=" ") # adds newline and inline + f = self.filename.open("w") f.write(result) - def write_curve(self, xmlNode, curve, fill='none', stroke='#000000', width=2): - """ Writes a Curve to the xml tree. This will draw a single curve - - :param xmlNode: Node in xml tree - :type xmlNode: Etree.element - :param curve : The spline curve to write - :type curve : Curve - :param fill : Fill color in hex or none - :type fill : String - :param stroke : Line color written in hex, i.e. '#000000' - :type stroke : String - :param width : Line width, measured in pixels - :type width : Int - :returns: None - :rtype : NoneType + def write_curve( + self, + xmlNode: etree.Element, + curve: Curve, + fill: str = "none", + stroke: str = "#000000", + width: int = 2, + ) -> None: + """Write a Curve to the xml tree. This will draw a single curve. + + :param xmlNode: Node in xml tree + :type xmlNode: Etree.element + :param curve : The spline curve to write + :type curve : Curve + :param fill : Fill color in hex or none + :type fill : String + :param stroke : Line color written in hex, i.e. '#000000' + :type stroke : String + :param width : Line width, measured in pixels + :type width : Int + :returns: None + :rtype : NoneType """ - curveNode = etree.SubElement(xmlNode, 'path') - curveNode.attrib['style'] = 'fill:%s;stroke:%s;stroke-width:%dpx;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1' %(fill,stroke,width) - bezier = bezier_representation(curve) + curveNode = etree.SubElement(xmlNode, "path") + curveNode.attrib["style"] = ( + "fill:%s;stroke:%s;stroke-width:%dpx;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + % (fill, stroke, width) + ) + bezier = bezier_representation(curve) bezier -= self.center bezier *= self.scale bezier += self.offset - pathString = 'M %f,%f C ' % (bezier[0][0], self.height + 2*self.margin - bezier[0][1]) + pathString = f"M {bezier[0][0]},{self.height + 2 * self.margin - bezier[0][1]} C " - for i in range(1,len(bezier)): - pathString += '%f,%f ' % (bezier[i][0], self.height + 2*self.margin - bezier[i][1]) + for i in range(1, len(bezier)): + pathString += f"{bezier[i][0]},{self.height + 2 * self.margin - bezier[i][1]}" - curveNode.attrib['d'] = pathString + curveNode.attrib["d"] = pathString - def write_surface(self, surface, fill='#ffcc99'): - """ Writes a Surface to the xml tree. This will draw the surface along with all knot lines + def write_surface(self, surface: Surface, fill: str = "#ffcc99") -> None: + """Write a Surface to the xml tree. This will draw the surface along with all knot lines. - :param surface: The spline surface to write - :type surface: Surface - :param fill : Surface color written in hex, i.e. '#ffcc99' - :type fill : String - :returns: None - :rtype : NoneType + :param surface: The spline surface to write + :type surface: Surface + :param fill : Surface color written in hex, i.e. '#ffcc99' + :type fill : String + :returns: None + :rtype : NoneType """ # fetch boundary curves and create a connected, oriented bezier loop from it bndry_curves = surface.edges() bndry_curves[0].reverse() bndry_curves[3].reverse() - boundary = bndry_curves[0] + boundary = bndry_curves[0] boundary.append(bndry_curves[2]) boundary.append(bndry_curves[1]) boundary.append(bndry_curves[3]) @@ -187,7 +230,7 @@ def write_surface(self, surface, fill='#ffcc99'): knotlines.append(surface.const_par_curve(k, 1)) # create a group node for all elements corresponding to this surface patch - groupNode = etree.SubElement(self.xmlRoot, 'g') + groupNode = etree.SubElement(self.xmlRoot, "g") # fill interior with a peach color self.write_curve(groupNode, boundary, fill, width=2) @@ -196,314 +239,335 @@ def write_surface(self, surface, fill='#ffcc99'): for meshline in knotlines: self.write_curve(groupNode, meshline, width=1) + def write(self, obj: Union[SplineObject, SplineModel, Sequence[SplineObject]]) -> None: + """Writes a list of planar curves and surfaces to vector graphics SVG file. + The image will never be stretched, and the geometry will keep width/height ratio + of original geometry, regardless of provided width/height ratio from arguments. - def write(self, obj): - """ Writes a list of planar curves and surfaces to vector graphics SVG file. - The image will never be stretched, and the geometry will keep width/height ratio - of original geometry, regardless of provided width/height ratio from arguments. - - :param obj: Primitives to write - :type obj: List of Curves and/or Surfaces - :returns: None - :rtype : NoneType + :param obj: Primitives to write + :type obj: List of Curves and/or Surfaces + :returns: None + :rtype : NoneType """ # actually this is a dummy method. It will collect all geometries provided # and ton't actually write them to file until __exit__ is called - if isinstance(obj[0], SplineObject): # input SplineModel or list + if isinstance(obj, Sequence): # input SplineModel or list for o in obj: self.write(o) return + assert isinstance(obj, SplineObject) if obj.dimension != 2: - raise RuntimeError('SVG files only applicable for 2D geometries') + raise RuntimeError("SVG files only applicable for 2D geometries") # have to clone stuff we put here, in case they change on the outside - self.all_objects.append( obj.clone() ) + self.all_objects.append(obj.clone()) - def read(self): + def read(self) -> list[SplineObject]: tree = etree.parse(self.filename) root = tree.getroot() - parent_map = dict((c, p) for p in tree.iter() for c in p) - if 'width' in root.attrib: - self.width,_ = read_number_and_unit(root.attrib['width']) - self.height,_ = read_number_and_unit(root.attrib['height']) + parent_map = {c: p for p in tree.iter() for c in p} + if "width" in root.attrib: + self.width, _ = read_number_and_unit(root.attrib["width"]) + self.height, _ = read_number_and_unit(root.attrib["height"]) result = [] - for path in root.iter(SVG.namespace + 'path'): - crvs = self.curves_from_path(path.attrib['d']) + for path in root.iter(SVG.namespace + "path"): + crvs = self.curves_from_path(path.attrib["d"]) parent = path while parent != root: - if 'transform' in parent.attrib: + if "transform" in parent.attrib: for crv in crvs: - self.transform(crv, parent.attrib['transform']) + self.transform(crv, parent.attrib["transform"]) parent = parent_map[parent] # invert y-values since these are image coordinates for crv in crvs: - crv *= [1,-1] + crv *= [1, -1] crv += [0, self.height] result.append(crv) return result - def transform(self, curve, operation): + def transform(self, curve: SplineObject, operation: str) -> None: # intended input operation string: 'translate(-10,-20) scale(2) rotate(45) translate(5,10)' - all_operations = re.findall(r'[^\)]*\)', operation.lower()) + all_operations = re.findall(r"[^\)]*\)", operation.lower()) all_operations.reverse() for one_operation in all_operations: - parts = re.search(r'([a-z]*)\w*\((.*)\)', one_operation.strip()) + parts = re.search(r"([a-z]*)\w*\((.*)\)", one_operation.strip()) + assert parts is not None func = parts.group(1) - args = [float(d) for d in parts.group(2).split(',')] - if func == 'translate': - if len(args)==1: + args = [float(d) for d in parts.group(2).split(",")] + if func == "translate": + if len(args) == 1: args.append(0) curve += args - elif func == 'scale': - if len(args)==1: + elif func == "scale": + if len(args) == 1: args.append(args[0]) curve *= args - elif func == 'rotate': - curve.rotate(-args[0]/360*2*np.pi) - elif func == 'matrix': - M = np.array([[args[0], args[2], args[4]], - [args[1], args[3], args[5]], - [ 0, 0, 1]]) + elif func == "rotate": + curve.rotate(-args[0] / 360 * 2 * np.pi) + elif func == "matrix": + M = np.array([[args[0], args[2], args[4]], [args[1], args[3], args[5]], [0, 0, 1]]) n = len(curve) if not curve.rational: cp = np.ones((n, 3)) # pad with weights=1 cp[:, :-1] = np.reshape(curve.controlpoints, (n, 2)) cp = cp @ M.T - curve.controlpoints = np.reshape(np.array(cp[:,:-1]), curve.controlpoints.shape) + curve.controlpoints = np.reshape(np.array(cp[:, :-1]), curve.controlpoints.shape) else: cp = np.reshape(curve.controlpoints, (n, 3)) cp = cp @ M.T curve.controlpoints = np.reshape(np.array(cp), curve.controlpoints.shape) - def curves_from_path(self, path): + def curves_from_path(self, path: str) -> list[SplineObject]: # see https://www.w3schools.com/graphics/svg_path.asp for documentation # and also https://www.w3.org/TR/SVG/paths.html # figure out the largest polynomial order of this path - if re.search('[cCsS]', path): - order = 4 - elif re.search('[qQtTaA]', path): - order = 3 - else: - order = 2 - last_curve = None - result = [] + # if re.search('[cCsS]', path): + # order = 4 + # elif re.search('[qQtTaA]', path): + # order = 3 + # else: + # order = 2 + last_curve: Optional[SplineObject] = None + result: list[SplineObject] = [] # each 'piece' is an operator (M,C,Q,L etc) and accomponying list of argument points - for piece in re.findall('[a-zA-Z][^a-zA-Z]*', path): - + for piece in re.findall("[a-zA-Z][^a-zA-Z]*", path): # if not single-letter command (i.e. 'z') - if len(piece)>1: + if len(piece) > 1: # points is a (string-)list of (x,y)-coordinates for the given operator - points = re.findall(r'-?\d+\.?\d*', piece[1:]) + points = re.findall(r"-?\d+\.?\d*", piece[1:]) - if piece[0].lower() != 'a' and piece[0].lower() != 'v' and piece[0].lower() != 'h': + if piece[0].lower() != "a" and piece[0].lower() != "v" and piece[0].lower() != "h": # convert string-list to a list of numpy arrays (of size 2) - np_pts = np.reshape(np.array(points).astype('float'), (int(len(points)/2),2)) + np_pts = np.reshape(np.array(points).astype("float"), (int(len(points) / 2), 2)) - if piece[0] == 'm' or piece[0] == 'M': + if piece[0] == "m" or piece[0] == "M": # I really hope it always start with a move command (think it does) startpoint = np_pts[0] if len(np_pts) > 1: - if piece[0] == 'M': - knot = [0] + list(range(len(np_pts))) + [len(np_pts)-1] + if piece[0] == "M": + knot = [0] + list(range(len(np_pts))) + [len(np_pts) - 1] curve_piece = Curve(BSplineBasis(2, knot), np_pts) - elif piece[0] == 'm': - knot = [0] + list(range(len(np_pts))) + [len(np_pts)-1] + elif piece[0] == "m": + knot = [0] + list(range(len(np_pts))) + [len(np_pts) - 1] controlpoints = [startpoint] for cp in np_pts[1:]: controlpoints.append(cp + controlpoints[-1]) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) else: continue - elif piece[0] == 'c': + elif piece[0] == "c": # cubic spline, relatively positioned controlpoints = [startpoint] - knot = list(range(int(len(np_pts)/3)+1)) * 3 + knot = list(range(int(len(np_pts) / 3) + 1)) * 3 knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: - startpoint = controlpoints[int((len(controlpoints)-1)/3)*3] + startpoint = controlpoints[int((len(controlpoints) - 1) / 3) * 3] controlpoints.append(cp + startpoint) curve_piece = Curve(BSplineBasis(4, knot), controlpoints) - elif piece[0] == 'C': + elif piece[0] == "C": # cubic spline, absolute position controlpoints = [startpoint] - knot = list(range(int(len(np_pts)/3)+1)) * 3 + knot = list(range(int(len(np_pts) / 3) + 1)) * 3 knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: controlpoints.append(cp) curve_piece = Curve(BSplineBasis(4, knot), controlpoints) - elif piece[0] == 's': + elif piece[0] == "s": # smooth cubic spline, relative position controlpoints = [startpoint] - knot = list(range(int(len(np_pts)/2)+1)) * 3 + knot = list(range(int(len(np_pts) / 2) + 1)) * 3 knot += [knot[0], knot[-1]] knot.sort() - x0 = np.array(last_curve[-1]) + assert last_curve is not None + x0 = np.array(last_curve[-1]) xn1 = np.array(last_curve[-2]) - controlpoints.append(2*x0 -xn1) + controlpoints.append(2 * x0 - xn1) startpoint = controlpoints[-1] for i, cp in enumerate(np_pts): - if i % 2 == 0 and i>0: + if i % 2 == 0 and i > 0: startpoint = controlpoints[-1] - controlpoints.append(2*controlpoints[-1] - controlpoints[-2]) + controlpoints.append(2 * controlpoints[-1] - controlpoints[-2]) controlpoints.append(cp + startpoint) curve_piece = Curve(BSplineBasis(4, knot), controlpoints) - elif piece[0] == 'S': + elif piece[0] == "S": # smooth cubic spline, absolute position controlpoints = [startpoint] - knot = list(range(int(len(np_pts)/2)+1)) * 3 + knot = list(range(int(len(np_pts) / 2) + 1)) * 3 knot += [knot[0], knot[-1]] knot.sort() - x0 = np.array(last_curve[-1]) + assert last_curve is not None + x0 = np.array(last_curve[-1]) xn1 = np.array(last_curve[-2]) - controlpoints.append(2*x0 -xn1) - for i,cp in enumerate(np_pts): - if i % 2 == 0 and i>0: - controlpoints.append(2*controlpoints[-1] - controlpoints[-2]) + controlpoints.append(2 * x0 - xn1) + for i, cp in enumerate(np_pts): + if i % 2 == 0 and i > 0: + controlpoints.append(2 * controlpoints[-1] - controlpoints[-2]) controlpoints.append(cp) curve_piece = Curve(BSplineBasis(4, knot), controlpoints) - elif piece[0] == 'q': + elif piece[0] == "q": # quadratic spline, relatively positioned controlpoints = [startpoint] - knot = list(range(int(len(np_pts)/2)+1)) * 2 + knot = list(range(int(len(np_pts) / 2) + 1)) * 2 knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: - startpoint = controlpoints[int((len(controlpoints)-1)/2)*2] + startpoint = controlpoints[int((len(controlpoints) - 1) / 2) * 2] controlpoints.append(cp + startpoint) curve_piece = Curve(BSplineBasis(3, knot), controlpoints) - elif piece[0] == 'Q': + elif piece[0] == "Q": # quadratic spline, absolute position controlpoints = [startpoint] - knot = list(range(len(np_pts)/2+1)) * 2 + knot = list(range(len(np_pts) // 2 + 1)) * 2 knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: controlpoints.append(cp) curve_piece = Curve(BSplineBasis(3, knot), controlpoints) - elif piece[0] == 'l': + elif piece[0] == "l": # linear spline, relatively positioned controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: startpoint = controlpoints[-1] controlpoints.append(cp + startpoint) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'L': + elif piece[0] == "L": # linear spline, absolute position controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: controlpoints.append(cp) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'h': + elif piece[0] == "h": # horizontal piece, relatively positioned - np_pts = np.array(points).astype('float') + np_pts = np.array(points).astype("float") controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: startpoint = controlpoints[-1] controlpoints.append(np.array([cp, 0]) + startpoint) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'H': + elif piece[0] == "H": # horizontal piece, absolute position - np_pts = np.array(points).astype('float') + np_pts = np.array(points).astype("float") controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: controlpoints.append([cp, startpoint[1]]) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'v': + elif piece[0] == "v": # vertical piece, relatively positioned - np_pts = np.array(points).astype('float') + np_pts = np.array(points).astype("float") controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: startpoint = controlpoints[-1] controlpoints.append(np.array([0, cp]) + startpoint) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'V': + elif piece[0] == "V": # vertical piece, absolute position - np_pts = np.array(points).astype('float') + np_pts = np.array(points).astype("float") controlpoints = [startpoint] - knot = list(range(len(np_pts)+1)) + knot = list(range(len(np_pts) + 1)) knot += [knot[0], knot[-1]] knot.sort() for cp in np_pts: controlpoints.append([startpoint[0], cp]) curve_piece = Curve(BSplineBasis(2, knot), controlpoints) - elif piece[0] == 'A' or piece[0] == 'a': - np_pts = np.reshape(np.array(points).astype('float'), (int(len(points)))) - rx = float(points[0]) - ry = float(points[1]) + elif piece[0] == "A" or piece[0] == "a": + np_pts = np.reshape(np.array(points).astype("float"), (int(len(points)))) + rx = float(points[0]) + ry = float(points[1]) x_axis_rotation = float(points[2]) - large_arc_flag = (points[3] != '0') - sweep_flag = (points[4] != '0') - xend = np.array([float(points[5]), float(points[6]) ]) - if piece[0] == 'a': + large_arc_flag = points[3] != "0" + sweep_flag = points[4] != "0" + xend = np.array([float(points[5]), float(points[6])]) + if piece[0] == "a": xend += startpoint - R = np.array([[ np.cos(x_axis_rotation), np.sin(x_axis_rotation)], - [-np.sin(x_axis_rotation), np.cos(x_axis_rotation)]]) - xp = np.linalg.solve(R, (startpoint - xend)/2) + R = np.array( + [ + [np.cos(x_axis_rotation), np.sin(x_axis_rotation)], + [-np.sin(x_axis_rotation), np.cos(x_axis_rotation)], + ] + ) + xp = np.linalg.solve(R, (startpoint - xend) / 2) if sweep_flag == large_arc_flag: - cprime = -(np.sqrt(abs(rx**2*ry**2 - rx**2*xp[1]**2 - ry**2*xp[0]**2) / - (rx**2*xp[1]**2 + ry**2*xp[0]**2)) * - np.array([rx*xp[1]/ry, -ry*xp[0]/rx])) + cprime = -( + np.sqrt( + abs(rx**2 * ry**2 - rx**2 * xp[1] ** 2 - ry**2 * xp[0] ** 2) + / (rx**2 * xp[1] ** 2 + ry**2 * xp[0] ** 2) + ) + * np.array([rx * xp[1] / ry, -ry * xp[0] / rx]) + ) else: - cprime = +(np.sqrt(abs(rx**2*ry**2 - rx**2*xp[1]**2 - ry**2*xp[0]**2) / - (rx**2*xp[1]**2 + ry**2*xp[0]**2)) * - np.array([rx*xp[1]/ry, -ry*xp[0]/rx])) - center = np.linalg.solve(R.T, cprime) + (startpoint+xend)/2 - def arccos(vec1, vec2): - return (np.sign(vec1[0]*vec2[1] - vec1[1]*vec2[0]) * - np.arccos(vec1.dot(vec2)/np.linalg.norm(vec1)/np.linalg.norm(vec2))) - tmp1 = np.divide( xp - cprime, [rx,ry]) - tmp2 = np.divide(-xp - cprime, [rx,ry]) - theta1 = arccos(np.array([1,0]), tmp1) - delta_t= arccos(tmp1, tmp2) % (2*np.pi) + cprime = +( + np.sqrt( + abs(rx**2 * ry**2 - rx**2 * xp[1] ** 2 - ry**2 * xp[0] ** 2) + / (rx**2 * xp[1] ** 2 + ry**2 * xp[0] ** 2) + ) + * np.array([rx * xp[1] / ry, -ry * xp[0] / rx]) + ) + center = np.linalg.solve(R.T, cprime) + (startpoint + xend) / 2 + + def arccos(vec1: FArray, vec2: FArray) -> float: + return cast( + float, + ( + np.sign(vec1[0] * vec2[1] - vec1[1] * vec2[0]) + * np.arccos(vec1.dot(vec2) / np.linalg.norm(vec1) / np.linalg.norm(vec2)) + ), + ) + + tmp1 = np.divide(xp - cprime, [rx, ry]) + tmp2 = np.divide(-xp - cprime, [rx, ry]) + theta1 = arccos(np.array([1, 0]), tmp1) + delta_t = arccos(tmp1, tmp2) % (2 * np.pi) if not sweep_flag and delta_t > 0: - delta_t -= 2*np.pi + delta_t -= 2 * np.pi elif sweep_flag and delta_t < 0: - delta_t += 2*np.pi - curve_piece = (curve_factory.circle_segment(delta_t)*[rx,ry]).rotate(theta1) + center + delta_t += 2 * np.pi + curve_piece = (curve_factory.circle_segment(delta_t) * [rx, ry]).rotate(theta1) + center # curve_piece = curve_factory.circle_segment(delta_t) - elif piece[0] == 'z' or piece[0] == 'Z': + elif piece[0] == "z" or piece[0] == "Z": # periodic curve # curve_piece = Curve(BSplineBasis(2), [startpoint, last_curve[0]]) # curve_piece.reparam([0, curve_piece.length()]) # last_curve.append(curve_piece).make_periodic(0) + assert last_curve is not None last_curve.make_periodic(0) result.append(last_curve) last_curve = None continue else: - raise RuntimeError('Unknown path parameter:' + piece) + raise RuntimeError("Unknown path parameter:" + piece) - if(curve_piece.length()>state.controlpoint_absolute_tolerance): - curve_piece.reparam([0, curve_piece.length()]) + if curve_piece.length() > state.controlpoint_absolute_tolerance: + curve_piece.reparam((0, curve_piece.length())) if last_curve is None: last_curve = curve_piece else: - last_curve.append(curve_piece) - startpoint = last_curve[-1,:2] # disregard rational weight (if any) + cast(Curve, last_curve).append(curve_piece) + assert last_curve is not None + startpoint = last_curve[-1, :2] # disregard rational weight (if any) if last_curve is not None: result.append(last_curve) diff --git a/splipy/io/threedm.py b/splipy/io/threedm.py index 14ee2e5..21f86cd 100644 --- a/splipy/io/threedm.py +++ b/splipy/io/threedm.py @@ -1,121 +1,154 @@ +from __future__ import annotations + +from itertools import chain +from typing import TYPE_CHECKING, Iterable, Iterator, Optional, Protocol, Sequence, Union, cast + import numpy as np -from itertools import chain, product -from splipy import Curve, Surface, BSplineBasis, curve_factory +import rhino3dm as rhino +from typing_extensions import Self + +from splipy import curve_factory +from splipy.basis import BSplineBasis +from splipy.curve import Curve +from splipy.surface import Surface + from .master import MasterIO -import splipy.state as state -from rhino3dm import Brep, File3dm -from rhino3dm import NurbsCurve, PolylineCurve, Circle, Polyline, BezierCurve, Arc, Line -from rhino3dm import NurbsSurface, Cylinder, Sphere, Extrusion -from rhino3dm import Curve as threedmCurve # name conflict with splipy -from rhino3dm import Surface as threedmSurface # name conflict with splipy + +if TYPE_CHECKING: + from pathlib import Path + from types import TracebackType + + from splipy.splinemodel import SplineModel + from splipy.splineobject import SplineObject + from splipy.types import FArray + + +# The rhino3dm library has incomplete type information. In particular, some +# classes which are iterable and support the sequence protocol don't advertise +# that fact. All the casting and the protocols here are just to fix that. +# TODO(Eivind): Remove all this cruft if Rhino ever fixes their types. +class Point: + X: float + Y: float + Z: float + W: float + + +class CurvePointList(Protocol): + def __getitem__(self, i: int) -> Point: + ... + + def __len__(self) -> int: + ... + + +class SurfacePointList(Protocol): + def __getitem__(self, i: tuple[int, int]) -> Point: + ... + class ThreeDM(MasterIO): + filename: str + trimming_curves: list + fstream: rhino.File3dm - def __init__(self, filename): - if filename[-4:] != '.3dm': - filename += '.3dm' - self.filename = filename + def __init__(self, filename: Union[str, Path]) -> None: + self.filename = str(filename) self.trimming_curves = [] - def __enter__(self): + def __enter__(self) -> Self: + self.fstream = rhino.File3dm.Read(self.filename) return self - def write(self, obj): - raise IOError('Writing to 3DM not supported') + def __exit__( + self, + exc_type: Optional[type[BaseException]], + exc_val: Optional[BaseException], + exc_tb: Optional[TracebackType], + ) -> None: + pass - def read(self): - if not hasattr(self, 'fstream'): - self.onlywrite = False - self.fstream = File3dm.Read(self.filename) + def write(self, obj: Union[SplineObject, SplineModel, Sequence[SplineObject]]) -> None: + raise OSError("Writing to 3DM not supported") - if self.onlywrite: - raise IOError('Could not read from file %s' % (self.filename)) + def read(self) -> list[SplineObject]: + result: list[SplineObject] = [] - result = [] + for obj in cast(Iterator[rhino.File3dmObject], self.fstream.Objects): + geom: Union[rhino.GeometryBase, rhino.Polyline] = obj.Geometry - for obj in self.fstream.Objects: - geom = obj.Geometry - print(geom) - if type(geom) is Extrusion: + if isinstance(geom, rhino.Extrusion): geom = geom.ToBrep(splitKinkyFaces=True) - if type(geom) is Brep: - for idx in range(len(geom.Faces)): - print(' ', geom.Faces[idx], "(", geom.Faces[idx].UnderlyingSurface(), ")") - nsrf = geom.Faces[idx].UnderlyingSurface().ToNurbsSurface() + + if isinstance(geom, rhino.Brep): + faces = cast(Sequence[rhino.BrepFace], geom.Faces) + for idx in range(len(faces)): + nsrf = faces[idx].UnderlyingSurface().ToNurbsSurface() result.append(self.read_surface(nsrf)) - if type(geom) is Line: - geom = result.append(curve_factory.line(geom.From, geom.To)) + if isinstance(geom, rhino.Line): + a = (geom.From.X, geom.From.Y, geom.From.Z) + b = (geom.To.X, geom.To.Y, geom.To.Z) + result.append(curve_factory.line(a, b)) continue - if type(geom) is PolylineCurve: + + if isinstance(geom, rhino.PolylineCurve): geom = geom.ToPolyline() - if type(geom) is Polyline or \ - type(geom) is Circle or \ - type(geom) is threedmCurve or \ - type(geom) is BezierCurve or \ - type(geom) is Arc: + + if isinstance(geom, (rhino.Polyline, rhino.Circle, rhino.Curve, rhino.BezierCurve, rhino.Arc)): geom = geom.ToNurbsCurve() - if type(geom) is NurbsCurve: + if isinstance(geom, rhino.NurbsCurve): result.append(self.read_curve(geom)) - if type(geom) is Cylinder or \ - type(geom) is Sphere or \ - type(geom) is threedmSurface: + if isinstance(geom, (rhino.Cylinder, rhino.Sphere, rhino.Surface)): geom = geom.ToNurbsSurface() - if type(geom) is NurbsSurface: + + if isinstance(geom, rhino.NurbsSurface): result.append(self.read_surface(geom)) return result - def read_surface(self, nsrf): - knotsu = [0] - for i in nsrf.KnotsU: - knotsu.append(i) - knotsu.append(knotsu[len(knotsu)-1]) + def read_surface(self, nsrf: rhino.NurbsSurface) -> Surface: + knotsu = np.fromiter(chain([0.0], cast(Iterable[float], nsrf.KnotsU), [0.0]), dtype=float) knotsu[0] = knotsu[1] + knotsu[-1] = knotsu[-2] - knotsv = [0] - for i in nsrf.KnotsV: - knotsv.append(i) - knotsv.append(knotsv[len(knotsv)-1]) + knotsv = np.fromiter(chain([0.0], cast(Iterable[float], nsrf.KnotsV), [0.0]), dtype=float) knotsv[0] = knotsv[1] + knotsv[-1] = knotsv[-2] basisu = BSplineBasis(nsrf.OrderU, knotsu, -1) basisv = BSplineBasis(nsrf.OrderV, knotsv, -1) - cpts = [] - - cpts = np.ndarray((nsrf.Points.CountU*nsrf.Points.CountV, 3 + nsrf.IsRational)) - for v in range(0,nsrf.Points.CountV): - for u in range(0,nsrf.Points.CountU): - cpts[u+v*nsrf.Points.CountU,0] = nsrf.Points[u,v].X - cpts[u+v*nsrf.Points.CountU,1] = nsrf.Points[u,v].Y - cpts[u+v*nsrf.Points.CountU,2] = nsrf.Points[u,v].Z + + cpts: FArray = np.ndarray((nsrf.Points.CountU * nsrf.Points.CountV, 3 + nsrf.IsRational), dtype=float) + points = cast(SurfacePointList, nsrf.Points) + for v in range(nsrf.Points.CountV): + count_u = nsrf.Points.CountU + for u in range(count_u): + cpts[u + v * count_u, 0] = points[u, v].X + cpts[u + v * count_u, 1] = points[u, v].Y + cpts[u + v * count_u, 2] = points[u, v].Z if nsrf.IsRational: - cpts[u+v*nsrf.Points.CountU,3] = nsrf.Points[u,v].W + cpts[u + v * count_u, 3] = points[u, v].W return Surface(basisu, basisv, cpts, nsrf.IsRational) - def read_curve(self, ncrv): - knots = [0] - for i in ncrv.Knots: - knots.append(i) + def read_curve(self, ncrv: rhino.NurbsCurve) -> Curve: + knots = np.fromiter(chain([0.0], cast(Iterable[float], ncrv.Knots), [0.0]), dtype=float) knots[0] = knots[1] - knots.append(knots[len(knots)-1]) + knots[-1] = knots[-2] + basis = BSplineBasis(ncrv.Order, knots, -1) - cpts = [] - cpts = np.ndarray((len(ncrv.Points), ncrv.Dimension + ncrv.IsRational)) - for u in range(0,len(ncrv.Points)): - cpts[u,0] = ncrv.Points[u].X - cpts[u,1] = ncrv.Points[u].Y + points = cast(CurvePointList, ncrv.Points) + cpts: FArray = np.ndarray((len(points), ncrv.Dimension + ncrv.IsRational)) + for u in range(0, len(points)): + cpts[u, 0] = points[u].X + cpts[u, 1] = points[u].Y if ncrv.Dimension > 2: - cpts[u,2] = ncrv.Points[u].Z + cpts[u, 2] = points[u].Z if ncrv.IsRational: - cpts[u,3] = ncrv.Points[u].W + cpts[u, 3] = points[u].W return Curve(basis, cpts, ncrv.IsRational) - - def __exit__(self, exc_type, exc_value, traceback): - # Apperently File3DM objects don't need to dedicated cleanup/close code - pass diff --git a/splipy/splinemodel.py b/splipy/splinemodel.py index 3d0e9eb..1e1c1bc 100644 --- a/splipy/splinemodel.py +++ b/splipy/splinemodel.py @@ -1,34 +1,48 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from collections import Counter, OrderedDict, namedtuple -from itertools import chain, product, permutations, islice +from collections import Counter, OrderedDict +from collections.abc import MutableMapping +from dataclasses import dataclass +from itertools import chain, islice, permutations, product from operator import itemgetter -from typing import Callable, Dict, List, Tuple, Any, Optional +from pathlib import Path +from typing import Callable, Iterator, Literal, Optional, Sequence, TypeVar, Union, cast import numpy as np +from numpy.typing import NDArray +from typing_extensions import Self, Unpack -from .splineobject import SplineObject -from .utils import check_section, sections, section_from_index, section_to_index, uniquify, is_right_hand -from .utils import bisect from . import state +from .splineobject import SplineObject +from .types import FArray, Scalar, Section, SectionElt, SectionKwargs, SectionLike +from .utils import ( + bisect, + check_section, + is_right_hand, + section_from_index, + section_to_index, + sections, + uniquify, +) -try: - from collections.abc import MutableMapping -except ImportError: - from collections import MutableMapping +IArray = NDArray[np.int_] -def _section_to_index(section): +def _section_to_index(section: Section) -> tuple[Union[Literal[-1, 0], slice], ...]: """Replace all `None` in `section` with `slice(None)`, so that it works as a numpy array indexing tuple. """ return tuple(slice(None) if s is None else s for s in section) -face_t = np.dtype([('nodes', int, (4,)), ('owner', int, ()), ('neighbor', int, ()), ('name', object, ())]) +face_t = np.dtype([("nodes", int, (4,)), ("owner", int, ()), ("neighbor", int, ()), ("name", object, ())]) + +T = TypeVar("T") +G = TypeVar("G", bound=np.generic) -class VertexDict(MutableMapping): + +class VertexDict(MutableMapping[FArray, T]): """A dictionary where the keys are numpy arrays, and where equality is computed in an approximate sense for floating point numbers. @@ -38,20 +52,19 @@ class VertexDict(MutableMapping): rtol: float atol: float - _keys: List[Optional[np.ndarray]] - _values: List[Any] + _keys: list[Optional[FArray]] + _values: list[Optional[T]] - lut: Dict[Tuple[int, ...], List[Tuple[int, float]]] + lut: dict[tuple[int, ...], list[tuple[int, float]]] - def __init__(self, rtol=1e-5, atol=1e-8): - # List of (key, value) pairs + def __init__(self, rtol: float = 1e-5, atol: float = 1e-8) -> None: self.rtol = rtol self.atol = atol self._keys = [] self._values = [] - self.lut = dict() + self.lut = {} - def _bounds(self, key): + def _bounds(self, key: Scalar) -> tuple[Scalar, Scalar]: if key >= self.atol: return ( (key - self.atol) / (1 + self.rtol), @@ -69,14 +82,14 @@ def _bounds(self, key): (key + self.atol) / (1 - self.rtol), ) - def _candidate(self, key): + def _candidate(self, key: FArray) -> int: """Return the internal index for the first stored mapping that matches the given key. :param numpy.array key: The key to look for :raises KeyError: If the key is not found """ - candidates = None + candidates: Optional[set[int]] = None for coord, k in np.ndenumerate(key): lut = self.lut.setdefault(coord, []) minval, maxval = self._bounds(k) @@ -86,12 +99,14 @@ def _candidate(self, key): candidates = {i for i, _ in lut[lo:hi]} else: candidates &= {i for i, _ in lut[lo:hi]} + + assert candidates is not None for c in candidates: if self._keys[c] is not None: return c raise KeyError(key) - def _insert(self, key, value): + def _insert(self, key: FArray, value: T) -> None: newindex = len(self._values) for coord, v in np.ndenumerate(key): lut = self.lut.setdefault(coord, []) @@ -99,7 +114,7 @@ def _insert(self, key, value): self._keys.append(key) self._values.append(value) - def __setitem__(self, key, value): + def __setitem__(self, key: FArray, value: T) -> None: """Assign a key to a value.""" try: c = self._candidate(key) @@ -107,15 +122,15 @@ def __setitem__(self, key, value): except KeyError: self._insert(key, value) - def __getitem__(self, key): + def __getitem__(self, key: FArray) -> T: """Gets the value assigned to a key. :raises KeyError: If the key is not found """ c = self._candidate(key) - return self._values[c] + return cast(T, self._values[c]) - def __delitem__(self, key): + def __delitem__(self, key: FArray) -> None: """Deletes an assignment.""" try: i = self._candidate(key) @@ -124,18 +139,16 @@ def __delitem__(self, key): self._keys[i] = None self._values[i] = None - def __iter__(self): + def __iter__(self) -> Iterator[FArray]: """Iterate over all keys. .. note:: This generates all the stored keys, not all matching keys. """ - yield from self._keys - - def items(self): - """Return a list of key, value pairs.""" - yield from self._values + for key in self._keys: + if key is not None: + yield key - def __len__(self): + def __len__(self) -> int: """Returns the number of stored assignments.""" return len(self._values) @@ -145,15 +158,19 @@ class OrientationError(RuntimeError): :class:`splipy.SplineModel.Orientation` indicating an inability to match two objects. """ + pass + class TwinError(RuntimeError): """A `TwinError` is raised when two objects with identical interfaces are added, but different interiors. """ + pass -class Orientation(object): + +class Orientation: """An `Orientation` represents a mapping between two coordinate systems: the *reference* system and the *actual* or *mapped* system. @@ -165,7 +182,11 @@ class Orientation(object): direction `d` *in the reference system* should be reversed. """ - def __init__(self, perm, flip): + perm: tuple[int, ...] + perm_inv: tuple[int, ...] + flip: tuple[bool, ...] + + def __init__(self, perm: tuple[int, ...], flip: tuple[bool, ...]): """Initialize an Orientation object. .. warning:: This constructor is for internal use. Use @@ -177,7 +198,7 @@ def __init__(self, perm, flip): self.perm_inv = tuple(perm.index(d) for d in range(len(perm))) @classmethod - def compute(cls, cpa, cpb=None): + def compute(cls, cpa: SplineObject, cpb: Optional[SplineObject] = None) -> Self: """Compute and return a new orientation object representing the mapping between `cpa` (the reference system) and `cpb` (the mapped system). @@ -194,8 +215,10 @@ def compute(cls, cpa, cpb=None): # Return the identity orientation if no cpb if cpb is None: - return cls(tuple(range(pardim)), - tuple(False for _ in range(pardim))) + return cls( + tuple(range(pardim)), + (False,) * pardim, + ) # Deal with the easy cases: dimension mismatch, and # comparing the shapes as multisets @@ -234,19 +257,28 @@ def compute(cls, cpa, cpb=None): for flip in product([False, True], repeat=pardim): slices = tuple(slice(None, None, -1) if f else slice(None) for f in flip) test_b = transposed[slices + (slice(None),)] - if np.allclose(cps_a, test_b, - rtol=state.controlpoint_relative_tolerance, - atol=state.controlpoint_absolute_tolerance): - if all([cpa.bases[i].matches(cpb.bases[perm[i]], reverse=flip[i]) for i in range(pardim)]): - return cls(perm, flip) + + is_close = np.allclose( + cps_a, + test_b, + rtol=state.controlpoint_relative_tolerance, + atol=state.controlpoint_absolute_tolerance, + ) + + bases_match = all( + cpa.bases[i].matches(cpb.bases[perm[i]], reverse=flip[i]) for i in range(pardim) + ) + + if is_close and bases_match: + return cls(perm, flip) raise OrientationError("Non-matching objects") @property - def pardim(self): + def pardim(self) -> int: return len(self.perm) - def __mul__(self, other): + def __mul__(self, other: Orientation) -> Orientation: """Compose two mappings. If `ort_left` maps system `A` (reference) to system `B`, and @@ -261,13 +293,13 @@ def __mul__(self, other): return Orientation(perm, flip) - def map_array(self, array): + def map_array(self, array: NDArray[G]) -> NDArray[G]: """Map an array in the mapped system to the reference system.""" array = array.transpose(*self.perm) flips = tuple(slice(None, None, -1) if f else slice(None) for f in self.flip) return array[flips] - def map_section(self, section): + def map_section(self, section: SectionLike) -> Section: """Map a section in the mapped system to the reference system. The input is a section tuple as described in @@ -277,8 +309,11 @@ def map_section(self, section): """ permuted = tuple(section[d] for d in self.perm) - flipped = () - for s, f, in zip(permuted, self.flip): + flipped: Section = () + for ( + s, + f, + ) in zip(permuted, self.flip): # Flipping only applies to indexed directions, not variable ones if f and s is not None: flipped += (0 if s == -1 else -1,) @@ -287,7 +322,7 @@ def map_section(self, section): return flipped - def view_section(self, section): + def view_section(self, section: Section) -> Self: """Reduce a mapping to a lower dimension. The input is a section tuple as described in @@ -315,7 +350,7 @@ def view_section(self, section): return self.__class__(new_perm, new_flip) @property - def ifem_format(self): + def ifem_format(self) -> int: """Compute the orientation in IFEM format. For one-dimensional objects, this is a single binary digit indicating @@ -336,20 +371,18 @@ def ifem_format(self): return 0 if len(self.flip) == 1: return int(self.flip[0]) - elif len(self.flip) == 2: + if len(self.flip) == 2: ret = 0 for i, axis in enumerate(self.perm[::-1]): if self.flip[axis]: ret |= 1 << i - if tuple(self.perm) == (1,0): + if tuple(self.perm) == (1, 0): ret |= 1 << 2 return ret - raise RuntimeError( - 'IFEM orientation format not supported for pardim {}'.format(len(self.flip)) - ) + raise RuntimeError(f"IFEM orientation format not supported for pardim {len(self.flip)}") -class TopologicalNode(object): +class TopologicalNode: """A `TopologicalNode` object refers to a single, persistent point in the topological graph. It represents some object of dimension `d` (that is, a point, an edge, etc.) and it has references to all the other objects it @@ -375,7 +408,17 @@ class TopologicalNode(object): of any kind. """ - def __init__(self, obj, lower_nodes, index): + obj: SplineObject + lower_nodes: list[tuple[TopologicalNode, ...]] + higher_nodes: dict[int, list[TopologicalNode]] + index: int + owner: Optional[TopologicalNode] + + name: Optional[str] + cell_numbers: Optional[IArray] + cp_numbers: Optional[IArray] + + def __init__(self, obj: SplineObject, lower_nodes: list[tuple[TopologicalNode, ...]], index: int) -> None: """Initialize a `TopologicalNode` object associated with the given `SplineObject` and lower order nodes. @@ -404,26 +447,26 @@ def __init__(self, obj, lower_nodes, index): node._transfer_ownership(self) @property - def pardim(self): + def pardim(self) -> int: return self.obj.pardim @property - def nhigher(self): + def nhigher(self) -> int: return len(self.higher_nodes[self.pardim + 1]) @property - def super_owner(self): + def super_owner(self) -> TopologicalNode: """Return the highest owning node.""" owner = self while owner.owner is not None: owner = owner.owner return owner - def assign_higher(self, node): + def assign_higher(self, node: TopologicalNode) -> None: """Add a link to a node of higher dimension.""" - self.higher_nodes.setdefault(node.pardim, list()).append(node) + self.higher_nodes.setdefault(node.pardim, []).append(node) - def view(self, other_obj=None): + def view(self, other_obj: Optional[SplineObject] = None) -> NodeView: """Return a `NodeView` object of this node. The returned view has an orientation that matches that of the input @@ -434,12 +477,10 @@ def view(self, other_obj=None): underlying object """ if other_obj: - orientation = Orientation.compute(self.obj, other_obj) - else: - orientation = Orientation.compute(self.obj) - return NodeView(self, orientation) + return NodeView(self, Orientation.compute(self.obj, other_obj)) + return NodeView(self, Orientation.compute(self.obj)) - def _transfer_ownership(self, new_owner): + def _transfer_ownership(self, new_owner: TopologicalNode) -> None: """Transfers ownership of this node to a new owner. This operation is transitive, so all child nodes owned by this node, or who are owner-less will also be transferred. @@ -453,7 +494,7 @@ def _transfer_ownership(self, new_owner): if child.owner is self or child.owner is None: child._transfer_ownership(new_owner) - def generate_cp_numbers(self, start=0): + def generate_cp_numbers(self, start: int = 0) -> int: """Generate a control point numbering starting at `start`. Return the next unused index.""" assert self.owner is None @@ -463,7 +504,7 @@ def generate_cp_numbers(self, start=0): numbers[:] = 0 # Flag control points owned by other top-level objects with -1 - for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim-1)): + for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim - 1)): if node.owner is not self: numbers[_section_to_index(section)] = -1 @@ -476,29 +517,31 @@ def generate_cp_numbers(self, start=0): self.assign_cp_numbers(numbers) return start + nowned - def assign_cp_numbers(self, numbers): + def assign_cp_numbers(self, numbers: IArray) -> None: """Directly assign control point numbers.""" self.cp_numbers = numbers # Control point numbers for owned children must be communicated to them if self.pardim > 0: - for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim-1)): + for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim - 1)): if node.owner is self or node.owner is self.owner: # Since this runs in a direct line of ownership, we don't need to be concerned with # orientations not matching up. node.assign_cp_numbers(numbers[_section_to_index(section)]) - def read_cp_numbers(self): + def read_cp_numbers(self) -> None: """Read control point numbers for unowned control points from child nodes.""" - for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim-1)): + assert self.cp_numbers is not None + for node, section in zip(self.lower_nodes[-1], sections(self.pardim, self.pardim - 1)): + assert node.cp_numbers is not None if node.owner is not self: # The two sections may not agree on orientation, so we fix this here. - ori = Orientation.compute(self.obj.section(*section), node.obj) + ori = Orientation.compute(self.obj.section(*section, unwrap_points=False), node.obj) self.cp_numbers[_section_to_index(section)] = ori.map_array(node.cp_numbers) assert (self.cp_numbers != -1).all() - def generate_cell_numbers(self, start=0): + def generate_cell_numbers(self, start: int = 0) -> int: """Generate a cell numbering starting at `start`. Return the next unused index.""" assert self.owner is None @@ -506,17 +549,25 @@ def generate_cell_numbers(self, start=0): shape = [len(kvec) - 1 for kvec in self.obj.knots()] nelems = np.prod(shape) self.cell_numbers = np.reshape(np.arange(start, start + nelems, dtype=int), shape) - return start + nelems + return start + int(nelems) - def faces(self): + def faces(self) -> list[NDArray]: """Return all faces owned by this node, as a list of numpy arrays with dtype `face_t`.""" assert self.pardim == 3 - assert self.obj.order() == (2,2,2) + assert self.obj.order() == (2, 2, 2) + assert self.cp_numbers is not None + assert self.cell_numbers is not None + shape = [len(kvec) - 1 for kvec in self.obj.knots()] ncells = np.prod(shape) retval = [] - def mkindex(dim, z, a, b): + def mkindex( + dim: int, + z: Union[slice, int], + a: Union[slice, int], + b: Union[slice, int], + ) -> tuple[Union[slice, int], ...]: rval = [a, b] if dim != 1 else [b, a] rval.insert(dim, z) return tuple(rval) @@ -528,15 +579,15 @@ def mkindex(dim, z, a, b): # First, get all internal faces in this direction # The owner (lowest cell index) is guaranteed to be toward the lower end - # TODO: We assume a right-hand coordinate system here + # TODO(Eivind): We assume a right-hand coordinate system here nfaces = ncells - nperslice faces = np.empty((nfaces,), dtype=face_t) - faces['nodes'][:,0] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[:-1], np.s_[:-1])].flatten() - faces['nodes'][:,1] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[1:], np.s_[:-1])].flatten() - faces['nodes'][:,2] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[1:], np.s_[1:])].flatten() - faces['nodes'][:,3] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[:-1], np.s_[1:])].flatten() - faces['owner'] = self.cell_numbers[mkindex(d, np.s_[:-1], np.s_[:], np.s_[:])].flatten() - faces['neighbor'] = self.cell_numbers[mkindex(d, np.s_[1:], np.s_[:], np.s_[:])].flatten() + faces["nodes"][:, 0] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[:-1], np.s_[:-1])].flatten() + faces["nodes"][:, 1] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[1:], np.s_[:-1])].flatten() + faces["nodes"][:, 2] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[1:], np.s_[1:])].flatten() + faces["nodes"][:, 3] = self.cp_numbers[mkindex(d, np.s_[1:-1], np.s_[:-1], np.s_[1:])].flatten() + faces["owner"] = self.cell_numbers[mkindex(d, np.s_[:-1], np.s_[:], np.s_[:])].flatten() + faces["neighbor"] = self.cell_numbers[mkindex(d, np.s_[1:], np.s_[:], np.s_[:])].flatten() retval.append(faces) # Go through the two boundaries @@ -548,49 +599,52 @@ def mkindex(dim, z, a, b): continue faces = np.empty((nperslice,), dtype=face_t) - faces['nodes'][:,0] = self.cp_numbers[mkindex(d, bdindex, np.s_[:-1], np.s_[:-1])].flatten() - faces['nodes'][:,1] = self.cp_numbers[mkindex(d, bdindex, np.s_[1:], np.s_[:-1])].flatten() - faces['nodes'][:,2] = self.cp_numbers[mkindex(d, bdindex, np.s_[1:], np.s_[1:])].flatten() - faces['nodes'][:,3] = self.cp_numbers[mkindex(d, bdindex, np.s_[:-1], np.s_[1:])].flatten() - faces['owner'] = self.cell_numbers[mkindex(d, bdindex, np.s_[:], np.s_[:])].flatten() - faces['name'] = bdnode.name + faces["nodes"][:, 0] = self.cp_numbers[mkindex(d, bdindex, np.s_[:-1], np.s_[:-1])].flatten() + faces["nodes"][:, 1] = self.cp_numbers[mkindex(d, bdindex, np.s_[1:], np.s_[:-1])].flatten() + faces["nodes"][:, 2] = self.cp_numbers[mkindex(d, bdindex, np.s_[1:], np.s_[1:])].flatten() + faces["nodes"][:, 3] = self.cp_numbers[mkindex(d, bdindex, np.s_[:-1], np.s_[1:])].flatten() + faces["owner"] = self.cell_numbers[mkindex(d, bdindex, np.s_[:], np.s_[:])].flatten() + faces["name"] = bdnode.name # If we're on the left boundary, the face normal must point in the other direction # NOTE: We copy when swapping here, since we are swapping values which are views into # a mutable array! if bdindex == 0: - faces['nodes'][:,1], faces['nodes'][:,3] = ( - faces['nodes'][:,3].copy(), faces['nodes'][:,1].copy() + faces["nodes"][:, 1], faces["nodes"][:, 3] = ( + faces["nodes"][:, 3].copy(), + faces["nodes"][:, 1].copy(), ) # If there's a neighbor on the interface we need neighbouring cell numbers if bdnode.nhigher == 1: - faces['neighbor'] = -1 + faces["neighbor"] = -1 else: neighbor = next(c for c in bdnode.higher_nodes[3] if c is not self) + assert neighbor.cell_numbers is not None # Find out which face the interface is as numbered from the neighbor's perspective nb_index = neighbor.lower_nodes[2].index(bdnode) # Get the spline object on that interface as oriented from the neighbor's perspective nb_sec = section_from_index(3, 2, nb_index) - nb_obj = neighbor.obj.section(*nb_sec) + nb_obj = neighbor.obj.section(*nb_sec, unwrap_points=False) # Compute the relative orientation ori = Orientation.compute(bdnode.obj, nb_obj) - # Get the neighbor cell numbers from the neighbor's perspective, and map them to our system + # Get the neighbor cell numbers from the neighbor's perspective, + # and map them to our system cellidxs = neighbor.cell_numbers[_section_to_index(nb_sec)] - faces['neighbor'] = ori.map_array(cellidxs).flatten() + faces["neighbor"] = ori.map_array(cellidxs).flatten() retval.append(faces) for faces in retval: - assert ((faces['owner'] < faces['neighbor']) | (faces['neighbor'] == -1)).all() + assert ((faces["owner"] < faces["neighbor"]) | (faces["neighbor"] == -1)).all() return retval -class NodeView(object): +class NodeView: """A `NodeView` object refers to a *view* to a point in the topological graph. It is composed of a node (:class:`splipy.SplineModel.TopologicalNode`) and an orientation (:class:`splipy.SplineModel.Orienation`). @@ -599,7 +653,10 @@ class NodeView(object): persistent. """ - def __init__(self, node, orientation=None): + node: TopologicalNode + orientation: Optional[Orientation] + + def __init__(self, node: TopologicalNode, orientation: Optional[Orientation] = None) -> None: """Initialize a `NodeView` object with the given node and orientation. .. warning:: This constructor is for internal use. @@ -608,18 +665,18 @@ def __init__(self, node, orientation=None): self.orientation = orientation @property - def pardim(self): + def pardim(self) -> int: return self.node.pardim @property - def name(self): + def name(self) -> Optional[str]: return self.node.name @name.setter - def name(self, value): + def name(self, value: str) -> None: self.node.name = value - def section(self, *args, **kwargs): + def section(self, *args: SectionElt, **kwargs: Unpack[SectionKwargs]) -> NodeView: """Return a section. See :func:`splipy.SplineObject.section` for more details on the input arguments. @@ -631,6 +688,7 @@ def section(self, *args, **kwargs): tgt_dim = sum(1 for s in section if s is None) # The index of the section in the reference system + assert self.orientation is not None ref_idx = section_to_index(self.orientation.map_section(section)) # The underlying node @@ -643,40 +701,48 @@ def section(self, *args, **kwargs): return NodeView(node, ref_ori * my_ori) - def corner(self, i): + def corner(self, i: int) -> NodeView: """Return the i'th corner.""" return self.section(*section_from_index(self.pardim, 0, i)) @property - def corners(self): + def corners(self) -> tuple[NodeView, ...]: """A tuple of all corners.""" - return tuple(self.section(s) for s in sections(self.pardim, 0)) + return tuple(self.section(*s) for s in sections(self.pardim, 0)) - def edge(self, i): + def edge(self, i: int) -> NodeView: """Return the i'th edge.""" return self.section(*section_from_index(self.pardim, 1, i)) @property - def edges(self): + def edges(self) -> tuple[NodeView, ...]: """A tuple of all edges.""" - return tuple(self.section(s) for s in sections(self.pardim, 1)) + return tuple(self.section(*s) for s in sections(self.pardim, 1)) - def face(self, i): + def face(self, i: int) -> NodeView: """Return the i'th face.""" return self.section(*section_from_index(self.pardim, 2, i)) @property - def faces(self): + def faces(self) -> tuple[NodeView, ...]: """A tuple of all faces.""" - return tuple(self.section(s) for s in sections(self.pardim, 2)) + return tuple(self.section(*s) for s in sections(self.pardim, 2)) -class ObjectCatalogue(object): +class ObjectCatalogue: """An `ObjectCatalogue` maintains a complete topological graph of objects with at most `pardim` parametric directions. """ - def __init__(self, pardim): + pardim: int + count: int + + internal: OrderedDict[tuple[TopologicalNode, ...], list[TopologicalNode]] + + lower: Union[ObjectCatalogue, VertexDict[TopologicalNode]] + callbacks: dict[str, list[Callable[[TopologicalNode], None]]] + + def __init__(self, pardim: int) -> None: """Initialize a catalogue for objects of parametric dimension `pardim`. """ @@ -694,13 +760,13 @@ def __init__(self, pardim): self.lower = VertexDict() # Callbacks for events - self.callbacks = dict() + self.callbacks = {} - def add_callback(self, event: str, callback: Callable[[TopologicalNode], None]): + def add_callback(self, event: str, callback: Callable[[TopologicalNode], None]) -> None: """Add a callback function to be called on a given event.""" self.callbacks.setdefault(event, []).append(callback) - def lookup(self, obj, add=False, raise_on_twins=()): + def lookup(self, obj: SplineObject, add: bool = False, raise_on_twins: Sequence[int] = ()) -> NodeView: """Obtain the `NodeView` object corresponding to a given object. If the keyword argument `add` is true, this function may generate one @@ -724,10 +790,12 @@ def lookup(self, obj, add=False, raise_on_twins=()): """ # Pass lower-dimensional objects through to the lower levels if self.pardim > obj.pardim: + assert isinstance(self.lower, ObjectCatalogue) return self.lower.lookup(obj, add=add, raise_on_twins=raise_on_twins) # Special case for points: self.lower is a mapping from array to node if self.pardim == 0: + assert isinstance(self.lower, VertexDict) cps = obj.controlpoints if obj.rational: cps = cps[..., :-1] @@ -735,18 +803,23 @@ def lookup(self, obj, add=False, raise_on_twins=()): node = TopologicalNode(obj, [], index=self.count) self.count += 1 rval = self.lower.setdefault(cps, node).view() - for cb in self.callbacks.get('add', []): + for cb in self.callbacks.get("add", []): cb(node) return rval return self.lower[cps].view() + assert isinstance(self.lower, ObjectCatalogue) + # Get all nodes of lower dimension (points, vertices, etc.) # This involves a recursive call to self.lower.__call__ lower_nodes = [] for i in range(0, self.pardim): - nodes = tuple(self.lower.lookup(obj.section(*args, unwrap_points=False), add=add, - raise_on_twins=raise_on_twins).node - for args in sections(self.pardim, i)) + nodes = tuple( + self.lower.lookup( + obj.section(*args, unwrap_points=False), add=add, raise_on_twins=raise_on_twins + ).node + for args in sections(self.pardim, i) + ) lower_nodes.append(nodes) # Try looking up the lower-order nodes in the internal dictionary, @@ -763,8 +836,7 @@ def lookup(self, obj, add=False, raise_on_twins=()): if not candidates: if not add: raise KeyError("No such object found") - else: - return self._add(obj, lower_nodes) + return self._add(obj, lower_nodes) # If there is exactly one candidate, check it if len(candidates) == 1: @@ -795,7 +867,7 @@ def lookup(self, obj, add=False, raise_on_twins=()): raise KeyError("No such object found") return self._add(obj, lower_nodes) - def add(self, obj, raise_on_twins=()): + def add(self, obj: SplineObject, raise_on_twins: Sequence[int] = ()) -> NodeView: """Add new nodes to the graph to accommodate the given object, then return the corresponding `NodeView` object. @@ -820,7 +892,7 @@ def add(self, obj, raise_on_twins=()): """ return self.lookup(obj, add=True, raise_on_twins=raise_on_twins) - def _add(self, obj, lower_nodes): + def _add(self, obj: SplineObject, lower_nodes: list[tuple[TopologicalNode, ...]]) -> NodeView: node = TopologicalNode(obj, lower_nodes, index=self.count) self.count += 1 # Assign the new node to each possible permutation of lower-order @@ -829,76 +901,102 @@ def _add(self, obj, lower_nodes): perms = set(permutations(lower_nodes[-1])) for p in perms: self.internal.setdefault(p, []).append(node) - for cb in self.callbacks.get('add', []): + for cb in self.callbacks.get("add", []): cb(node) return node.view() __call__ = add __getitem__ = lookup - def top_nodes(self): + def top_nodes(self) -> list[TopologicalNode]: """Return all nodes of the highest parametric dimension.""" return self.nodes(self.pardim) - def nodes(self, pardim): + def nodes(self, pardim: int) -> list[TopologicalNode]: """Return all nodes of a given parametric dimension.""" if self.pardim == pardim: if self.pardim > 0: return list(uniquify(chain.from_iterable(self.internal.values()))) + assert isinstance(self.lower, VertexDict) return list(uniquify(self.lower.values())) + assert isinstance(self.lower, ObjectCatalogue) return self.lower.nodes(pardim) -# FIXME: This class is unfinished, and right now it doesn't do much other than -# wrap ObjectCatalogue - -class SplineModel(object): - - def __init__(self, pardim=3, dimension=3, objs=[], force_right_hand=False): +# TODO(Eivind): This class is unfinished, and right now it doesn't do much other than wrap ObjectCatalogue +class SplineModel: + pardim: int + dimension: int + force_right_hand: bool + catalogue: ObjectCatalogue + names: dict[str, SplineObject] + + def __init__( + self, + pardim: int = 3, + dimension: int = 3, + objs: Sequence[SplineObject] = (), + force_right_hand: bool = False, + ) -> None: self.pardim = pardim self.dimension = dimension self.force_right_hand = force_right_hand - if force_right_hand and (pardim, dimension) not in ((2,2), (3,3)): - raise ValueError("Right-handedness only defined for 2D or 3D patches in 2D or 3D space, respectively") + if force_right_hand and (pardim, dimension) not in ((2, 2), (3, 3)): + raise ValueError( + "Right-handedness only defined for 2D or 3D patches in 2D or 3D space, respectively" + ) self.catalogue = ObjectCatalogue(pardim) self.names = {} self.add(objs) - def add_callback(self, event: str, callback: Callable[[TopologicalNode], None]): - catalogue = self.catalogue + def add_callback(self, event: str, callback: Callable[[TopologicalNode], None]) -> None: + catalogue: Union[ObjectCatalogue, VertexDict] = self.catalogue while isinstance(catalogue, ObjectCatalogue): catalogue.add_callback(event, callback) catalogue = catalogue.lower - def add(self, obj, name=None, raise_on_twins=True): + def add( + self, + obj: Union[SplineObject, Sequence[SplineObject]], + name: Optional[str] = None, + raise_on_twins: Union[bool, Sequence[int]] = True, + ) -> None: + rot: tuple[int, ...] if raise_on_twins is True: - raise_on_twins = tuple(range(self.pardim + 1)) + rot = tuple(range(self.pardim + 1)) elif raise_on_twins is False: - raise_on_twins = () - if isinstance(obj, SplineObject): - obj = [obj] - self._validate(obj) - self._generate(obj, raise_on_twins=raise_on_twins) - if name and isinstance(obj, SplineObject): - self.names[name] = obj - - def __getitem__(self, obj): + rot = () + else: + rot = tuple(raise_on_twins) + objs = [obj] if isinstance(obj, SplineObject) else obj + + self._validate(objs) + self._generate(objs, raise_on_twins=rot) + if name: + for obj in objs: + self.names[name] = obj + + def __getitem__(self, obj: SplineObject) -> NodeView: return self.catalogue[obj] - def boundary(self, name=None): - for node in self.catalogue.nodes(self.pardim-1): + def __iter__(self) -> Iterator[SplineObject]: + for node in self.catalogue.top_nodes(): + yield node.obj + + def boundary(self, name: Optional[str] = None) -> Iterator[TopologicalNode]: + for node in self.catalogue.nodes(self.pardim - 1): if node.nhigher == 1 and (name is None or name == node.name): yield node - def assign_boundary(self, name): + def assign_boundary(self, name: str) -> None: """Give a name to all unnamed boundary nodes.""" for node in self.boundary(): if node.name is None: node.name = name - def _validate(self, objs): + def _validate(self, objs: Sequence[SplineObject]) -> None: if any(p.dimension != self.dimension for p in objs): raise ValueError("Patches with different dimension added") if any(p.pardim > self.pardim for p in objs): @@ -906,24 +1004,23 @@ def _validate(self, objs): if self.force_right_hand: left_inds = [i for i, p in enumerate(objs) if not is_right_hand(p)] if left_inds: - indices = ', '.join(map(str, left_inds)) + indices = ", ".join(map(str, left_inds)) raise ValueError(f"Possibly left-handed patches detected, indexes {indices}") - def _generate(self, objs, **kwargs): + def _generate(self, objs: Sequence[SplineObject], raise_on_twins: Sequence[int]) -> None: for i, p in enumerate(objs): try: - self.catalogue.add(p, **kwargs) + self.catalogue.add(p, raise_on_twins=raise_on_twins) except OrientationError as err: - # TODO: Mutating exceptions is fishy. + # TODO(Eivind): Mutating exceptions is fishy. if len(err.args) > 1: err.args = ( - err.args[0] + - f" This happened while trying to connect patches at indexes" + err.args[0] + f" This happened while trying to connect patches at indexes" f" {err.args[1]} and {i}.", ) raise err - def generate_cp_numbers(self): + def generate_cp_numbers(self) -> None: index = 0 for node in self.catalogue.top_nodes(): index = node.generate_cp_numbers(index) @@ -931,61 +1028,69 @@ def generate_cp_numbers(self): for node in self.catalogue.top_nodes(): node.read_cp_numbers() - def generate_cell_numbers(self): + def generate_cell_numbers(self) -> None: index = 0 for node in self.catalogue.top_nodes(): index = node.generate_cell_numbers(index) self.ncells = index - def cps(self): - cps = np.zeros((self.ncps, self.dimension)) + def cps(self) -> FArray: + cps = np.zeros((self.ncps, self.dimension), dtype=float) for node in self.catalogue.top_nodes(): + assert node.cp_numbers is not None indices = node.cp_numbers.reshape(-1) values = node.obj.controlpoints.reshape(-1, self.dimension) cps[indices] = values return cps - def faces(self): + def faces(self) -> NDArray: assert self.pardim == 3 faces = list(chain.from_iterable(node.faces() for node in self.catalogue.top_nodes())) return np.hstack(faces) - def summary(self): - c = self.catalogue + def summary(self) -> None: + c: Union[ObjectCatalogue, VertexDict] = self.catalogue while isinstance(c, ObjectCatalogue): - print('Dim {}: {}'.format(c.pardim, len(c.top_nodes()))) + print(f"Dim {c.pardim}: {len(c.top_nodes())}") c = c.lower - def write_ifem(self, filename): + def write_ifem(self, filename: str) -> None: IFEMWriter(self).write(filename) - -IFEMConnection = namedtuple('IFEMConnection', ['master', 'slave', 'midx', 'sidx', 'orient']) +# TODO(Eivind): Py310 add slots=True +@dataclass(frozen=True) +class IFEMConnection: + master: int + slave: int + midx: int + sidx: int + orient: int class IFEMWriter: + model: SplineModel + + nodes: list[TopologicalNode] + node_ids: dict[TopologicalNode, int] - def __init__(self, model): + def __init__(self, model: SplineModel) -> None: self.model = model # List the nodes so that the order is deterministic self.nodes = list(model.catalogue.top_nodes()) self.node_ids = {node: i for i, node in enumerate(self.nodes)} - def connections(self): + def connections(self) -> Iterator[IFEMConnection]: p = self.model.pardim # For every object in the model... for node in self.nodes: - # Loop over its sections of one lower parametric dimension # That is, for faces, loop over edges, and for volumes, loop over faces for node_sub_idx, sub in enumerate(node.lower_nodes[p - 1]): - # Iterate over the neighbour nodes for neigh in set(sub.higher_nodes[p]): - # Only output if the node has a lower ID than the neighbour, # otherwise we'll get this pair when the reverse pair is found if self.node_ids[node] > self.node_ids[neigh]: @@ -1014,67 +1119,75 @@ def connections(self): orientation = Orientation.compute(node_sub, neigh_sub) yield IFEMConnection( - master = self.node_ids[node] + 1, - slave = self.node_ids[neigh] + 1, - midx = node_sub_idx + 1, - sidx = neigh_sub_idx + 1, - orient = orientation.ifem_format, + master=self.node_ids[node] + 1, + slave=self.node_ids[neigh] + 1, + midx=node_sub_idx + 1, + sidx=neigh_sub_idx + 1, + orient=orientation.ifem_format, ) - def write(self, filename): + def write(self, filename: Union[str, Path]) -> None: + filename = Path(filename) + lines = [ "", "", ] for connection in self.connections(): - lines.append(' '.format( + lines.append( + ' '.format( connection.master, connection.slave, connection.midx, connection.sidx, connection.orient, - )) + ) + ) lines.extend([""]) - with open(filename + '-topology.xinp', 'wb') as f: - f.write('\n'.join(lines).encode('utf-8') + b'\n') + with filename.with_name(f"{filename.name}-topology.xinp").open("wb") as f: + f.write("\n".join(lines).encode("utf-8") + b"\n") lines = [ "", "", ] - names = sorted({ - node.name for node in self.model.catalogue.nodes(self.model.pardim - 1) - if node.name is not None - }) + names = sorted( + {node.name for node in self.model.catalogue.nodes(self.model.pardim - 1) if node.name is not None} + ) for name in names: - entries = {} + entries: dict[int, set[int]] = {} for node in self.model.catalogue.nodes(self.model.pardim - 1): if node.name != name: continue parent = node.owner - sub_idx = next(idx for idx, sub in enumerate(parent.lower_nodes[self.model.pardim - 1]) if sub is node) + assert parent is not None + sub_idx = next( + idx for idx, sub in enumerate(parent.lower_nodes[self.model.pardim - 1]) if sub is node + ) entries.setdefault(self.node_ids[parent], set()).add(sub_idx) if entries: - kind = {2: 'face', 1: 'edge', 0: 'vertex'}[self.model.pardim - 1] - lines.append(' '.format(name, kind)) + kind = {2: "face", 1: "edge", 0: "vertex"}[self.model.pardim - 1] + lines.append(f' ') for node_id, sub_ids in entries.items(): - lines.append(' {}'.format( - node_id + 1, - ' '.join(str(i+1) for i in sorted(sub_ids)) - )) - lines.append(' ') + lines.append( + ' {}'.format( + node_id + 1, " ".join(str(i + 1) for i in sorted(sub_ids)) + ) + ) + lines.append(" ") lines.extend([""]) - with open(filename + '-topologysets.xinp', 'wb') as f: - f.write('\n'.join(lines).encode('utf-8') + b'\n') + with filename.with_name(f"{filename.name}-topologysets.xinp").open("wb") as f: + f.write("\n".join(lines).encode("utf-8") + b"\n") # Import here to avoid circular dependencies from .io import G2 - with G2(filename + '.g2') as f: + + with G2(filename.with_suffix(".g2"), "w") as f: f.write([n.obj for n in self.nodes]) diff --git a/splipy/splineobject.py b/splipy/splineobject.py index d713291..03bac32 100644 --- a/splipy/splineobject.py +++ b/splipy/splineobject.py @@ -1,41 +1,91 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -import numpy as np import copy -from operator import attrgetter, methodcaller -from itertools import chain, product from bisect import bisect_left +from itertools import product +from operator import attrgetter, methodcaller +from typing import ( + TYPE_CHECKING, + Any, + Callable, + ClassVar, + Generic, + Literal, + MutableSequence, + Optional, + Protocol, + Sequence, + SupportsFloat, + SupportsIndex, + TypeVar, + Union, + cast, + overload, +) + +import numpy as np +from numpy.typing import NDArray +from typing_extensions import Self, Unpack from .basis import BSplineBasis from .utils import ( - reshape, rotation_matrix, is_singleton, ensure_listlike, - check_direction, ensure_flatlist, check_section, sections, - raise_order_1D + check_direction, + check_section, + ensure_listlike, + ensure_scalars, + is_singleton, + raise_order_1D, + reshape, + rotation_matrix, + sections, ) -__all__ = ['SplineObject'] +if TYPE_CHECKING: + from numpy.typing import ArrayLike + + from .types import Direction, FArray, Scalar, ScalarOrScalars, Scalars, SectionElt, SectionKwargs + +__all__ = ["SplineObject"] + +IPArray = NDArray[np.intp] -def transpose_fix(pardim, direction): - ret = list(range(1, pardim+1)) + +def _transpose_fix(pardim: int, direction: int) -> tuple[int, ...]: + ret = list(range(1, pardim + 1)) ret.insert(direction, 0) return tuple(ret) -def evaluate(bases, cps, tensor=True): +def _evaluate(bases: Sequence[FArray], cps: FArray, tensor: bool = True) -> FArray: if tensor: idx = len(bases) - 1 for N in bases[::-1]: cps = np.tensordot(N, cps, axes=(1, idx)) else: - cps = np.einsum('ij,j...->i...', bases[0], cps) + cps = np.einsum("ij,j...->i...", bases[0], cps) for N in bases[1:]: - cps = np.einsum('ij,ij...->i...', N, cps) + cps = np.einsum("ij,ij...->i...", N, cps) return cps -class SplineObject(object): - """ Master class for spline objects with arbitrary dimensions. +# Standard constructor for SplineObjects +T = TypeVar("T", bound="SplineObject", covariant=True) + + +class Constructor(Protocol, Generic[T]): + def __call__( + self, + bases: Sequence[BSplineBasis], + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> T: + ... + + +class SplineObject: + """Master class for spline objects with arbitrary dimensions. This class should be subclassed instead of used directly. @@ -44,8 +94,39 @@ class SplineObject(object): object, while infix operators (e.g. ``+``) create new objects. """ - def __init__(self, bases=None, controlpoints=None, rational=False, raw=False): - """ Construct a spline object with the given bases and control points. + _intended_pardim: ClassVar[Optional[int]] = None + + dimension: int + bases: list[BSplineBasis] + controlpoints: FArray + rational: bool + + @staticmethod + def constructor(pardim: int) -> Constructor[SplineObject]: + constructor = next(iter([c for c in SplineObject.__subclasses__() if c._intended_pardim == pardim])) + + def wrapped_constructor( + bases: Sequence[BSplineBasis], + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> SplineObject: + return constructor(*bases, controlpoints, rational=rational, raw=raw) # type: ignore[arg-type, misc, call-arg] + + return wrapped_constructor + + @property + def self_constructor(self) -> Constructor[Self]: + return cast(Constructor[Self], SplineObject.constructor(self.pardim)) + + def __init__( + self, + bases: Sequence[Optional[BSplineBasis]], + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> None: + """Construct a spline object with the given bases and control points. The default is to create a linear one-element mapping from and to the unit (hyper)cube. @@ -59,44 +140,58 @@ def __init__(self, bases=None, controlpoints=None, rational=False, raw=False): :param bool raw: If True, skip any control point reordering. (For internal use.) """ - bases = [(b.clone() if b else BSplineBasis()) for b in bases] - self.bases = bases + + self.bases = [(b.clone() if b else BSplineBasis()) for b in bases] + if controlpoints is None: # `product' produces tuples in row-major format (the last input varies quickest) # We want them in column-major format, so we reverse the basis orders, and then # also reverse the output tuples - controlpoints = [c[::-1] for c in product(*(b.greville() for b in bases[::-1]))] + cps = np.array( + [c[::-1] for c in product(*(b.greville() for b in self.bases[::-1]))], + dtype=float, + ) # Minimum two dimensions - if len(controlpoints[0]) == 1: - controlpoints = [tuple(list(c) + [0.0]) for c in controlpoints] + m, n = cps.shape + if n == 1: + zeros = np.zeros_like(cps, shape=(m, 1)) + cps = np.concatenate((cps, zeros), axis=1) # Add weight = 1 for identiy-mapping rational splines if rational: - controlpoints = [tuple(list(c) + [1.0]) for c in controlpoints] + ones = np.ones_like(cps, shape=(m, 1)) + controlpoints = np.concatenate((cps, ones), axis=1) + + self.controlpoints = cps + + else: + self.controlpoints = np.array(controlpoints, dtype=float) - self.controlpoints = np.array(controlpoints) self.dimension = self.controlpoints.shape[-1] - rational self.rational = rational if not raw: - shape = tuple(b.num_functions() for b in bases) + shape = tuple(b.num_functions() for b in self.bases) ncomps = self.dimension + rational - self.controlpoints = reshape(self.controlpoints, shape, order='F', ncomps=ncomps) + self.controlpoints = reshape(self.controlpoints, shape, order="F", ncomps=ncomps) - def _validate_domain(self, *params): - """ Check whether the given evaluation parameters are valid. + def _validate_domain(self, *params: MutableSequence[float]) -> None: + """Check whether the given evaluation parameters are valid. :raises ValueError: If the parameters are outside the domain """ for b, p in zip(self.bases, params): b.snap(p) - if b.periodic < 0: - if min(p) < b.start() or b.end() < max(p): - raise ValueError('Evaluation outside parametric domain') + if b.periodic < 0 and (min(p) < b.start() or b.end() < max(p)): + raise ValueError("Evaluation outside parametric domain") - def evaluate(self, *params, **kwargs): - """ Evaluate the object at given parametric values. + def evaluate( + self, + *params: ScalarOrScalars, + tensor: bool = True, + ) -> FArray: + """Evaluate the object at given parametric values. If *tensor* is true, evaluation will take place on a tensor product grid, i.e. it will return an *n1* × *n2* × ... × *dim* array, where @@ -118,18 +213,17 @@ def evaluate(self, *params, **kwargs): :rtype: numpy.array """ squeeze = all(is_singleton(p) for p in params) - params = [ensure_listlike(p) for p in params] + params_list = [ensure_scalars(p) for p in params] - tensor = kwargs.get('tensor', True) - if not tensor and len({len(p) for p in params}) != 1: - raise ValueError('Parameters must have same length') + if not tensor and len({len(p) for p in params_list}) != 1: + raise ValueError("Parameters must have same length") - self._validate_domain(*params) + self._validate_domain(*params_list) # Evaluate the corresponding bases at the corresponding points # and build the result array - Ns = [b.evaluate(p) for b, p in zip(self.bases, params)] - result = evaluate(Ns, self.controlpoints, tensor) + Ns = [b.evaluate(p) for b, p in zip(self.bases, params_list)] + result = _evaluate(Ns, self.controlpoints, tensor) # For rational objects, we divide out the weights, which are stored in the # last coordinate @@ -144,8 +238,14 @@ def evaluate(self, *params, **kwargs): return result - def derivative(self, *params, **kwargs): - """ Evaluate the derivative of the object at the given parametric values. + def derivative( + self, + *params: ScalarOrScalars, + d: Union[int, Sequence[int]] = 1, + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> FArray: + """Evaluate the derivative of the object at the given parametric values. If *tensor* is true, evaluation will take place on a tensor product grid, i.e. it will return an *n1* × *n2* × ... × *dim* array, where @@ -189,25 +289,22 @@ def derivative(self, *params, **kwargs): :rtype: numpy.array """ squeeze = all(is_singleton(p) for p in params) - params = [ensure_listlike(p) for p in params] - - derivs = kwargs.get('d', [1] * self.pardim) - derivs = ensure_listlike(derivs, self.pardim) - - above = kwargs.get('above', [True] * self.pardim) + params_list = [ensure_scalars(p) for p in params] + derivs = ensure_listlike(d, self.pardim) above = ensure_listlike(above, self.pardim) - tensor = kwargs.get('tensor', True) - - if not tensor and len({len(p) for p in params}) != 1: - raise ValueError('Parameters must have same length') + if not tensor and len({len(p) for p in params_list}) != 1: + raise ValueError("Parameters must have same length") - self._validate_domain(*params) + self._validate_domain(*params_list) # Evaluate the derivatives of the corresponding bases at the corresponding points # and build the result array - dNs = [b.evaluate(p, d, from_right) for b, p, d, from_right in zip(self.bases, params, derivs, above)] - result = evaluate(dNs, self.controlpoints, tensor) + dNs = [ + b.evaluate_dense(p, d=d, from_right=from_right) + for b, p, d, from_right in zip(self.bases, params_list, derivs, above) + ] + result = _evaluate(dNs, self.controlpoints, tensor) # For rational curves, we need to use the quotient rule # (n/W)' = (n' W - n W') / W^2 = n'/W - nW'/W^2 @@ -216,11 +313,11 @@ def derivative(self, *params, **kwargs): # We evaluate in the regular way to compute n and W. if self.rational: if sum(derivs) > 1: - raise RuntimeError('Rational derivative not implemented for order %i' % sum(derivs)) - Ns = [b.evaluate(p) for b, p in zip(self.bases, params)] - non_derivative = evaluate(Ns, self.controlpoints, tensor) + raise RuntimeError("Rational derivative not implemented for order %i" % sum(derivs)) + Ns = [b.evaluate(p) for b, p in zip(self.bases, params_list)] + non_derivative = _evaluate(Ns, self.controlpoints, tensor) W = non_derivative[..., -1] # W - Wd = result[..., -1] # W' + Wd = result[..., -1] # W' for i in range(self.dimension): result[..., i] = result[..., i] / W - non_derivative[..., i] * Wd / W / W result = np.delete(result, self.dimension, -1) @@ -231,8 +328,16 @@ def derivative(self, *params, **kwargs): return result - def get_derivative_spline(self, direction=None): - """ Compute the controlpoints associated with the derivative spline object + @overload + def get_derivative_spline(self) -> tuple[Self, ...]: + ... + + @overload + def get_derivative_spline(self, direction: Direction) -> Self: + ... + + def get_derivative_spline(self, direction=None): # type: ignore[no-untyped-def] + """Compute the controlpoints associated with the derivative spline object If `direction` is given, only the derivatives in that direction are returned. @@ -264,44 +369,57 @@ def get_derivative_spline(self, direction=None): """ if self.rational: - raise RuntimeError('Not working for rational splines') + raise RuntimeError("Not working for rational splines") # if no direction is specified, return a tuple with all derivatives if direction is None: return tuple([self.get_derivative_spline(dim) for dim in range(self.pardim)]) - else: - d = check_direction(direction, self.pardim) - k = self.knots(d, with_multiplicities=True) - p = self.order(d)-1 - n = self.shape[d] - if self.bases[d].periodic < 0: - C = np.zeros((n-1, n)) - for i in range(n-1): - C[i,i] = -float(p) / (k[i+p+1] - k[i+1]) - C[i,i+1] = float(p) / (k[i+p+1] - k[i+1]) - else: - C = np.zeros((n, n)) - for i in range(n): - ip1 = np.mod(i+1,n) - C[i,i] = -float(p) / (k[i+p+1] - k[i+1]) - C[i,ip1] = float(p) / (k[i+p+1] - k[i+1]) - derivative_cps = np.tensordot(C, self.controlpoints, axes=(1, d)) - derivative_cps = derivative_cps.transpose(transpose_fix(self.pardim, d)) - bases = [b for b in self.bases] - bases[d] = BSplineBasis(p, k[1:-1], bases[d].periodic-1) - - # search for the right subclass constructor, i.e. Volume, Surface or Curve - constructor = [c for c in SplineObject.__subclasses__() if c._intended_pardim == len(self.bases)] - constructor = constructor[0] - - # return derivative object - args = bases + [derivative_cps] + [self.rational] - return constructor(*args, raw=True) - - - def tangent(self, *params, **kwargs): - """ Evaluate the tangents of the object at the given parametric values. + d = check_direction(direction, self.pardim) + k = self.knots(d, with_multiplicities=True) + p = self.order(d) - 1 + n = self.shape[d] + if self.bases[d].periodic < 0: + C = np.zeros((n - 1, n)) + for i in range(n - 1): + C[i, i] = -float(p) / (k[i + p + 1] - k[i + 1]) + C[i, i + 1] = float(p) / (k[i + p + 1] - k[i + 1]) + else: + C = np.zeros((n, n)) + for i in range(n): + ip1 = np.mod(i + 1, n) + C[i, i] = -float(p) / (k[i + p + 1] - k[i + 1]) + C[i, ip1] = float(p) / (k[i + p + 1] - k[i + 1]) + + derivative_cps = np.tensordot(C, self.controlpoints, axes=(1, d)) + derivative_cps = derivative_cps.transpose(_transpose_fix(self.pardim, d)) + bases = list(self.bases) + bases[d] = BSplineBasis(p, k[1:-1], bases[d].periodic - 1) + + return self.self_constructor(bases, derivative_cps, rational=self.rational, raw=True) + + @overload + def tangent( + self, + *params: ScalarOrScalars, + direction: Direction, + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> FArray: + ... + + @overload + def tangent( + self, + *params: ScalarOrScalars, + direction: None = None, + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> tuple[FArray, ...]: + ... + + def tangent(self, *params, direction=None, above=True, tensor=True): # type: ignore[no-untyped-def] + """Evaluate the tangents of the object at the given parametric values. If `direction` is given, only the derivatives in that direction are evaluated. This is equivalent to calling @@ -321,49 +439,62 @@ def tangent(self, *params, **kwargs): :return: Tangents :rtype: tuple """ - direction = kwargs.get('direction', None) - derivative = [0] * self.pardim - above = kwargs.get('above', [True] * self.pardim) + derivative = [0] * self.pardim above = ensure_listlike(above, self.pardim) - tensor = kwargs.get('tensor', True) - - if self.pardim == 1: # curves - direction = 0 - if direction is None: - result = () + result: tuple[FArray, ...] = () for i in range(self.pardim): derivative[i] = 1 - # compute velocity in this direction + + # Compute velocity in this direction v = self.derivative(*params, d=derivative, above=above, tensor=tensor) - # normalize - if len(v.shape)==1: - speed = np.linalg.norm(v) + + # Normalize + if v.ndim == 1: + v /= np.linalg.norm(v) else: - speed = np.linalg.norm( v, axis=-1) - speed = np.reshape(speed, speed.shape +(1,)) - # store in result tuple - result += (v/speed,) + speed = np.linalg.norm(v, axis=-1) + v /= np.reshape(speed, speed.shape + (1,)) + + # Store in result tuple + result += (v,) derivative[i] = 0 return result + if self.pardim == 1: # curves + direction = 0 + i = check_direction(direction, self.pardim) derivative[i] = 1 - # compute velocity in this direction + + # Compute velocity in this direction v = self.derivative(*params, d=derivative, above=above, tensor=tensor) - # normalize - if len(v.shape)==1: - speed = np.linalg.norm(v) + + # Normalize + if v.ndim == 1: + v /= np.linalg.norm(v) else: - speed = np.linalg.norm( v, axis=-1) - speed = np.reshape(speed, speed.shape +(1,)) + speed = np.linalg.norm(v, axis=-1) + v /= np.reshape(speed, speed.shape + (1,)) + + return v + + @overload + def section( + self, *args: SectionElt, unwrap_points: Literal[True] = True, **kwargs: Unpack[SectionKwargs] + ) -> Union[SplineObject, FArray]: + ... - return v / speed + @overload + def section( + self, *args: SectionElt, unwrap_points: Literal[False], **kwargs: Unpack[SectionKwargs] + ) -> SplineObject: + ... - def section(self, *args, **kwargs): - """ Returns a section from the object. A section can be any sub-object of + def section(self, *args, **kwargs): # type: ignore[no-untyped-def] + """Return a section from the object. A section can be any sub-object of parametric dimension not exceeding that of the object. E.g. for a volume, sections include vertices, edges, faces, etc. @@ -400,20 +531,20 @@ def section(self, *args, **kwargs): :rtype: SplineObject or np.array """ section = check_section(*args, pardim=self.pardim, **kwargs) - unwrap_points = kwargs.get('unwrap_points', True) + unwrap_points = kwargs.get("unwrap_points", True) slices = tuple(slice(None) if p is None else p for p in section) bases = [b for b, p in zip(self.bases, section) if p is None] if bases or not unwrap_points: - classes = [c for c in SplineObject.__subclasses__() if c._intended_pardim == len(bases)] - if classes: - args = bases + [self.controlpoints[slices], self.rational] - return classes[0](*args, raw=True) - return SplineObject(bases, self.controlpoints[slices], self.rational, raw=True) + if 1 <= len(bases) <= 3: + return SplineObject.constructor(len(bases))( + bases, self.controlpoints[slices], rational=self.rational, raw=True + ) + return SplineObject(bases, self.controlpoints[slices], rational=self.rational, raw=True) return self.controlpoints[slices] - def set_order(self, *order): - """ Set the polynomial order of the object. If only one argument is + def set_order(self, *order: int) -> Self: + """Set the polynomial order of the object. If only one argument is given, the order is set uniformly over all directions. :param int u,v,...: The new order in a given direction. @@ -421,19 +552,19 @@ def set_order(self, *order): :return: self """ if len(order) == 1: - order = [order[0]] * self.pardim + order = (order[0],) * self.pardim if not all(new >= old for new, old in zip(order, self.order())): raise ValueError("Cannot lower order using set_order") diff = [new - old for new, old in zip(order, self.order())] return self.raise_order(*diff) - def raise_order(self, *raises, direction=None): - """ Raise the polynomial order of the object. If only one + def raise_order(self, *raises: int, direction: Optional[Direction] = None) -> Self: + """Raise the polynomial order of the object. If only one argument is given, the order is raised equally over all directions, unless the `direction` argument is also given. The explicit version is only implemented on open knot vectors. The - function raise_order_implicit is used otherwise. + method raise_order_implicit is used otherwise. :param int u,v,...: Number of times to raise the order in a given direction. @@ -441,42 +572,52 @@ def raise_order(self, *raises, direction=None): :return: self """ if len(raises) == 1 and direction is None: - raises = [raises[0]] * self.pardim - elif len(raises) == 1: - newraises = [0] * self.pardim - newraises[check_direction(direction, self.pardim)] = raises[0] - raises = newraises - if not all(r >= 0 for r in raises): + raises_list = [raises[0]] * self.pardim + elif len(raises) == 1 and direction is not None: + raises_list = [0] * self.pardim + raises_list[check_direction(direction, self.pardim)] = raises[0] + else: + raises_list = list(raises) + + if not all(r >= 0 for r in raises_list): raise ValueError("Cannot lower order using raise_order") - if all(r == 0 for r in raises): + if all(r == 0 for r in raises_list): return self if any(b.continuity(b.knots[0]) < b.order or b.periodic > -1 for b in self.bases): - self.raise_order_implicit(*raises) + self.raise_order_implicit(*raises_list) return self - new_bases = [b.raise_order(r) for b, r in zip(self.bases, raises)] + new_bases = [b.raise_order(r) for b, r in zip(self.bases, raises_list)] d_p = self.pardim controlpoints = self.controlpoints - for i in range(0,d_p): + for i in range(0, d_p): dimensions = np.array(controlpoints.shape) - indices = np.array(range(0,d_p+1)) - indices[i],indices[d_p] = d_p,i - controlpoints = np.transpose(controlpoints,indices) - controlpoints = np.reshape(controlpoints,(np.prod(dimensions[indices[:-1]]),dimensions[i])) - controlpoints = raise_order_1D(controlpoints.shape[1]-1,self.order(i), - self.bases[i].knots,controlpoints,raises[i],self.bases[i].periodic) - controlpoints = np.reshape(controlpoints,np.append(dimensions[indices[:-1]],controlpoints.shape[1])) - controlpoints = np.transpose(controlpoints,indices) + indices = np.array(range(0, d_p + 1)) + indices[i], indices[d_p] = d_p, i + controlpoints = np.transpose(controlpoints, indices) + controlpoints = np.reshape(controlpoints, (np.prod(dimensions[indices[:-1]]), dimensions[i])) + controlpoints = raise_order_1D( + controlpoints.shape[1] - 1, + self.order(i), + self.bases[i].knots, + controlpoints, + raises_list[i], + self.bases[i].periodic, + ) + controlpoints = np.reshape( + controlpoints, np.append(dimensions[indices[:-1]], controlpoints.shape[1]) + ) + controlpoints = np.transpose(controlpoints, indices) self.controlpoints = controlpoints self.bases = new_bases return self - def raise_order_implicit(self, *raises): - """ Raise the polynomial order of the object. If only one argument is + def raise_order_implicit(self, *raises: int) -> Self: + """Raise the polynomial order of the object. If only one argument is given, the order is raised equally over all directions. :param int u,v,...: Number of times to raise the order in a given @@ -495,19 +636,19 @@ def raise_order_implicit(self, *raises): # Calculate the projective interpolation points result = self.controlpoints for n in N_old[::-1]: - result = np.tensordot(n, result, axes=(1, self.pardim-1)) + result = np.tensordot(n, result, axes=(1, self.pardim - 1)) # Solve the interpolation problem for n in N_new[::-1]: - result = np.tensordot(np.linalg.inv(n), result, axes=(1, self.pardim-1)) + result = np.tensordot(np.linalg.inv(n), result, axes=(1, self.pardim - 1)) self.controlpoints = result self.bases = new_bases return self - def lower_order(self, *lowers): - """ Lower the polynomial order of the object. If only one argument is + def lower_order(self, *lowers: int) -> Self: + """Lower the polynomial order of the object. If only one argument is given, the order is lowered equally over all directions. :param int u,v,...: Number of times to lower the order in a given @@ -516,7 +657,7 @@ def lower_order(self, *lowers): order basis """ if len(lowers) == 1: - lowers = [lowers[0]] * self.pardim + lowers = (lowers[0],) * self.pardim if all(l == 0 for l in lowers): return self.clone() @@ -531,22 +672,25 @@ def lower_order(self, *lowers): # Calculate the projective interpolation points new_controlpts = self.controlpoints for n in N_old[::-1]: - new_controlpts = np.tensordot(n, new_controlpts, axes=(1, self.pardim-1)) + new_controlpts = np.tensordot(n, new_controlpts, axes=(1, self.pardim - 1)) # Solve the interpolation problem for n in N_new[::-1]: - new_controlpts = np.tensordot(np.linalg.inv(n), new_controlpts, axes=(1, self.pardim-1)) + new_controlpts = np.tensordot(np.linalg.inv(n), new_controlpts, axes=(1, self.pardim - 1)) # search for the right subclass constructor, i.e. Volume, Surface or Curve - constructor = [c for c in SplineObject.__subclasses__() if c._intended_pardim == len(self.bases)] - constructor = constructor[0] + return self.self_constructor(new_bases, new_controlpts, rational=self.rational, raw=True) + + @overload + def start(self) -> tuple[int, ...]: + ... - # return approximated object - args = new_bases + [new_controlpts] + [self.rational] - return constructor(*args, raw=True) + @overload + def start(self, direction: Direction, /) -> int: + ... - def start(self, direction=None): - """ Return the start of the parametric domain. + def start(self, direction=None): # type: ignore[no-untyped-def] + """Return the start of the parametric domain. If `direction` is given, returns the start of that direction, as a float. If it is not given, returns the start of all directions, as a @@ -560,8 +704,16 @@ def start(self, direction=None): direction = check_direction(direction, self.pardim) return self.bases[direction].start() - def end(self, direction=None): - """ Return the end of the parametric domain. + @overload + def end(self) -> tuple[int, ...]: + ... + + @overload + def end(self, direction: Direction, /) -> int: + ... + + def end(self, direction=None): # type: ignore[no-untyped-def] + """Return the end of the parametric domain. If `direction` is given, returns the end of that direction, as a float. If it is not given, returns the end of all directions, as a tuple. @@ -571,11 +723,19 @@ def end(self, direction=None): """ if direction is None: return tuple(b.end() for b in self.bases) - direction = check_direction(direction, self.pardim) - return self.bases[direction].end() + direction_index = check_direction(direction, self.pardim) + return self.bases[direction_index].end() + + @overload + def order(self) -> tuple[int, ...]: + ... - def order(self, direction=None): - """ Return polynomial order (degree + 1). + @overload + def order(self, direction: Direction, /) -> int: + ... + + def order(self, direction=None): # type: ignore[no-untyped-def] + """Return polynomial order (degree + 1). If `direction` is given, returns the order of that direction, as an int. If it is not given, returns the order of all directions, as a @@ -589,8 +749,16 @@ def order(self, direction=None): direction = check_direction(direction, self.pardim) return self.bases[direction].order - def knots(self, direction=None, with_multiplicities=False): - """ Return knots vector + @overload + def knots(self, /, with_multiplicities: bool = False) -> tuple[FArray, ...]: + ... + + @overload + def knots(self, direction: Direction, /, with_multiplicities: bool = False) -> FArray: + ... + + def knots(self, direction=None, with_multiplicities=False): # type: ignore[no-untyped-def] + """Return knots vector If `direction` is given, returns the knots in that direction, as a list. If it is not given, returns the knots of all directions, as a @@ -601,35 +769,36 @@ def knots(self, direction=None, with_multiplicities=False): multiplicities (i.e. repeated). :raises ValueError: For invalid direction """ - getter = attrgetter('knots') if with_multiplicities else methodcaller('knot_spans') + getter: Callable[[BSplineBasis], FArray] + getter = attrgetter("knots") if with_multiplicities else methodcaller("knot_spans") # type: ignore[assignment] + if direction is None: return tuple(getter(b) for b in self.bases) - direction = check_direction(direction, self.pardim) - return getter(self.bases[direction]) + direction_index = check_direction(direction, self.pardim) + return getter(self.bases[direction_index]) - def reverse(self, direction=0): - """ Swap the direction of a parameter by making it go in the reverse + def reverse(self, direction: Direction = 0) -> Self: + """Swap the direction of a parameter by making it go in the reverse direction. The parametric domain remains unchanged. :param int direction: The direction to flip. :return: self """ - direction = check_direction(direction, self.pardim) - self.bases[direction].reverse() + direction_index = check_direction(direction, self.pardim) + self.bases[direction_index].reverse() # This creates the following slice programmatically # array[:, :, :, ..., ::-1,] # index=direction -----^ # : => slice(None, None, None) # ::-1 => slice(None, None, -1) - direction = check_direction(direction, self.pardim) - slices = [slice(None, None, None) for _ in range(direction)] + [slice(None, None, -1)] + slices = [slice(None, None, None) for _ in range(direction_index)] + [slice(None, None, -1)] self.controlpoints = self.controlpoints[tuple(slices)] return self - def swap(self, dir1=0, dir2=1): - """ Swaps two parameter directions. + def swap(self, dir1: Direction = 0, dir2: Direction = 1, /) -> Self: + """Swap two parameter directions. This function silently passes for curves. @@ -638,24 +807,24 @@ def swap(self, dir1=0, dir2=1): :return: self """ if self.pardim == 1: - return + return self - dir1 = check_direction(dir1, self.pardim) - dir2 = check_direction(dir2, self.pardim) + dir1_index = check_direction(dir1, self.pardim) + dir2_index = check_direction(dir2, self.pardim) # Reorder control points new_directions = list(range(self.pardim + 1)) - new_directions[dir1] = dir2 - new_directions[dir2] = dir1 + new_directions[dir1_index] = dir2_index + new_directions[dir2_index] = dir1_index self.controlpoints = self.controlpoints.transpose(new_directions) # Swap knot vectors - self.bases[dir1], self.bases[dir2] = self.bases[dir2], self.bases[dir1] + self.bases[dir1_index], self.bases[dir2_index] = self.bases[dir2_index], self.bases[dir1_index] return self - def insert_knot(self, knot, direction=0): - """ Insert a new knot into the spline. + def insert_knot(self, knot: ScalarOrScalars, direction: Direction = 0) -> Self: + """Insert a new knot into the spline. :param int direction: The direction to insert in :param knot: The new knot(s) to insert @@ -663,23 +832,31 @@ def insert_knot(self, knot, direction=0): :raises ValueError: For invalid direction :return: self """ - shape = self.controlpoints.shape + shape = self.controlpoints.shape # for single-value input, wrap it into a list - knot = ensure_listlike(knot) + knot_list = ensure_scalars(knot) - direction = check_direction(direction, self.pardim) + direction_index = check_direction(direction, self.pardim) - C = np.identity(shape[direction]) - for k in knot: - C = self.bases[direction].insert_knot(k) @ C - self.controlpoints = np.tensordot(C, self.controlpoints, axes=(1, direction)) - self.controlpoints = self.controlpoints.transpose(transpose_fix(self.pardim, direction)) + C = np.identity(shape[direction_index]) + for k in knot_list: + C = self.bases[direction_index].insert_knot(k) @ C + self.controlpoints = np.tensordot(C, self.controlpoints, axes=(1, direction_index)) + self.controlpoints = self.controlpoints.transpose(_transpose_fix(self.pardim, direction_index)) return self - def refine(self, *ns, **kwargs): - """ Enrich the spline space by inserting knots into each existing knot + @overload + def refine(self, n: int, /, direction: Direction) -> Self: + ... + + @overload + def refine(self, *args: int) -> Self: + ... + + def refine(self, *ns, **kwargs): # type: ignore[no-untyped-def] + """Enrich the spline space by inserting knots into each existing knot span. This method supports three different usage patterns: @@ -699,27 +876,38 @@ def refine(self, *ns, **kwargs): :param int direction: Direction to refine in :return: self """ - direction = kwargs.get('direction', None) + direction = kwargs.get("direction", None) if len(ns) == 1 and direction is not None: - directions = [check_direction(direction, self.pardim)] + directions = iter([check_direction(direction, self.pardim)]) else: - directions = range(self.pardim) + directions = iter(range(self.pardim)) - if len(ns) == 1: - ns = [ns[0]] * self.pardim + factors: Sequence[int] = ns if len(ns) > 1 else [ns[0]] * self.pardim - for n, d in zip(ns, directions): - knots = self.knots(direction=d) # excluding multiple knots + for n, d in zip(factors, directions): + knots = self.knots(d) # excluding multiple knots new_knots = [] - for (k0, k1) in zip(knots[:-1], knots[1:]): - new_knots.extend(np.linspace(k0, k1, n+2)[1:-1]) + for k0, k1 in zip(knots[:-1], knots[1:]): + new_knots.extend(np.linspace(k0, k1, n + 2)[1:-1]) self.insert_knot(new_knots, d) return self - def reparam(self, *args, **kwargs): - """ Redefine the parametric domain. This function accepts two calling + @overload + def reparam(self, *args: Union[FArray, tuple[Scalar, Scalar]]) -> Self: + ... + + @overload + def reparam(self, arg: Union[FArray, tuple[Scalar, Scalar]], /, direction: Direction) -> Self: + ... + + @overload + def reparam(self, /, direction: Direction) -> Self: + ... + + def reparam(self, *args, **kwargs): # type: ignore[no-untyped-def] + """Redefine the parametric domain. This function accepts two calling conventions: `reparametrize(u, v, ...)` reparametrizes each direction to the domains @@ -735,24 +923,23 @@ def reparam(self, *args, **kwargs): :param int direction: The direction to reparametrize :return: self """ - if 'direction' not in kwargs: + if "direction" not in kwargs: # Pad the args with (0, 1) for the extra directions - args = list(args) + [(0, 1)] * (len(self.bases) - len(args)) - for b, (start, end) in zip(self.bases, args): + intervals: list[tuple[Scalar, Scalar]] = list(args) + [(0, 1)] * (len(self.bases) - len(args)) + for b, (start, end) in zip(self.bases, intervals): b.reparam(start, end) else: - direction = kwargs['direction'] - direction = check_direction(direction, self.pardim) + direction = check_direction(kwargs["direction"], self.pardim) if len(args) == 0: - self.bases[direction].reparam(0,1) + self.bases[direction].reparam(0, 1) else: start, end = args[0] self.bases[direction].reparam(start, end) return self - def translate(self, x): - """ Translate (i.e. move) the object by a given distance. + def translate(self, x: Scalars) -> Self: + """Translate (i.e. move) the object by a given distance. :param array-like x: The vector to translate by. :return: self @@ -796,8 +983,8 @@ def translate(self, x): return self - def scale(self, *args): - """ Scale, or magnify the object by a given amount. + def scale(self, *args: Scalar) -> Self: + """Scale, or magnify the object by a given amount. In case of one input argument, the scaling is uniform. @@ -815,8 +1002,7 @@ def scale(self, *args): dim = self.dimension rat = self.rational n = len(self) # number of control points - s = ensure_flatlist(args) - s = ensure_listlike(s, dups=3) + s = ensure_scalars(args, dups=3) # set up the scaling matrix scale_matrix = np.identity(dim + rat) @@ -830,12 +1016,12 @@ def scale(self, *args): cp = cp @ scale_matrix # store results - self.controlpoints = np.reshape(np.array(cp), self.controlpoints.shape) + self.controlpoints = np.reshape(cp, self.controlpoints.shape) return self - def rotate(self, theta, normal=(0, 0, 1)): - """ Rotate the object around an axis. + def rotate(self, theta: Scalar, normal: Scalars = (0, 0, 1)) -> Self: + """Rotate the object around an axis. :param float theta: Angle to rotate about, measured in radians :param array-like normal: The normal axis (if 3D) to rotate about @@ -858,13 +1044,14 @@ def rotate(self, theta, normal=(0, 0, 1)): # set up the rotation matrix if dim == 2: - R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)] - ]).T # we do right-multiplication, so we need a transpose + R = np.array( + [[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]] + ).T # we do right-multiplication, so we need a transpose elif dim == 3: normal = np.array(normal) R = rotation_matrix(theta, normal) else: - raise RuntimeError('rotation undefined for geometries other than 2D and 3D') + raise RuntimeError("rotation undefined for geometries other than 2D and 3D") rot_matrix = np.identity(dim + rat) rot_matrix[0:dim, 0:dim] = R @@ -880,8 +1067,8 @@ def rotate(self, theta, normal=(0, 0, 1)): return self - def mirror(self, normal): - """ Mirror the object around a plane through the origin. + def mirror(self, normal: Scalars) -> Self: + """Mirror the object around a plane through the origin. :param array-like normal: The plane normal to mirror about. :raises RuntimeError: If the physical dimension is not 2 or 3 @@ -904,15 +1091,15 @@ def mirror(self, normal): n = len(self) # number of control points if dim != 3: - raise RuntimeError('reflection undefined for geometries other than 3D') + raise RuntimeError("reflection undefined for geometries other than 3D") # fixup the input normal to right form - normal = np.array(normal) - normal = normal / np.sqrt(np.dot(normal, normal)) # normalize it + normal_vec = np.asarray(normal, dtype=float) + normal_vec /= np.sqrt(np.dot(normal_vec, normal_vec)) # normalize it # set up the reflection matrix reflection_matrix = np.identity(dim + rat) - reflection_matrix[0:dim, 0:dim] -= 2 * np.outer(normal, normal) + reflection_matrix[0:dim, 0:dim] -= 2 * np.outer(normal_vec, normal_vec) # wrap out the controlpoints to a matrix (down from n-D tensor) cp = np.reshape(self.controlpoints, (n, dim + rat)) @@ -925,8 +1112,8 @@ def mirror(self, normal): return self - def project(self, plane): - """ Projects the geometry onto a plane or axis. + def project(self, plane: Literal["", "x", "y", "z", "xy", "yz", "xz", "xyz"]) -> Self: + """Project the geometry onto a plane or axis. - `project('xy')` will project the object onto the *xy* plane, setting all *z* components to zero. @@ -936,7 +1123,7 @@ def project(self, plane): :param string plane: Any combination of 'x', 'y' and 'z' :return: self """ - keep = [c in plane.lower() for c in 'xyz'] + keep = [c in plane.lower() for c in "xyz"] dim = self.dimension for i in range(dim): @@ -945,8 +1132,8 @@ def project(self, plane): return self - def bounding_box(self): - """ Gets the bounding box of a spline object, computed from the + def bounding_box(self) -> list[tuple[float, float]]: + """Get the bounding box of a spline object, computed from the control-point values. Could be inaccurate for rational splines. Returns the minima and maxima for each direction: @@ -959,12 +1146,16 @@ def bounding_box(self): result = [] for i in range(dim): - result.append((np.min(self.controlpoints[..., i]), - np.max(self.controlpoints[..., i]))) + result.append( + ( + np.min(self.controlpoints[..., i]), + np.max(self.controlpoints[..., i]), + ) + ) return result - def center(self): - """ Gets the center of the domain + def center(self) -> FArray: + """Get the center of the domain For curves this will return :math:`(\\tilde{x}, \\tilde{y},...)`, where @@ -976,7 +1167,8 @@ def center(self): .. math:: \\tilde{x} = \\frac{1}{A} \\int_{v_0}^{v_1} \\int_{u_0}^{u_1} x(u,v) \\; du \\; dv - and :math:`A=(u_1-u_0)(v_1-v_0)` is the area of the parametric domain :math:`[u_0,u_1]\\times[v_0,v_1]`. + and :math:`A=(u_1-u_0)(v_1-v_0)` is the area of the parametric domain + :math:`[u_0,u_1]\\times[v_0,v_1]`. .. warning:: For rational splines, this will integrate in projective coordinates, then project the centerpoint. This is as opposed to @@ -988,7 +1180,7 @@ def center(self): Ns = [b.integrate(b.start(), b.end()) for b in self.bases] # compute parametric size - par_size = np.prod([t1-t0 for (t0,t1) in zip(self.start(), self.end())]) + par_size = np.prod([t1 - t0 for (t0, t1) in zip(self.start(), self.end())]) # multiply basis functions with control points idx = self.pardim - 1 @@ -1006,8 +1198,8 @@ def center(self): return result - def corners(self, order='C'): - """ Return the corner control points. + def corners(self, order: Literal["C", "F"] = "C") -> FArray: + """Return the corner control points. The `order` parameter determines which order to use, either ``'F'`` or ``'C'``, for row-major or column-major ordering. E.g. for a volume, in @@ -1023,35 +1215,35 @@ def corners(self, order='C'): .. warning:: For rational splines, this will return the corners in projective coordinates, including weights. """ - result = np.zeros((2**self.pardim, self.dimension + int(self.rational))) + result = np.zeros((2**self.pardim, self.dimension + int(self.rational)), dtype=float) for i, args in enumerate(sections(self.pardim, 0)): - result[i,:] = self.section(*(args[::-1] if order == 'F' else args)) + result[i, :] = self.section(*(args[::-1] if order == "F" else args)) return result - def lower_periodic(self, periodic, direction=0): - """ Sets the periodicity of the spline object in the given direction, + def lower_periodic(self, periodic: int, direction: Direction = 0) -> Self: + """Set the periodicity of the spline object in the given direction, keeping the geometry unchanged. :param int periodic: new periodicity, i.e. the basis is C^k over the start/end :param int direction: the parametric direction of the basis to modify :return: self """ - direction = check_direction(direction, self.pardim) + direction_index = check_direction(direction, self.pardim) - b = self.bases[direction] + b = self.bases[direction_index] while periodic < b.periodic: - self.insert_knot(self.start(direction), direction) - self.controlpoints = np.roll(self.controlpoints, -1, direction) + self.insert_knot(self.start(direction_index), direction_index) + self.controlpoints = np.roll(self.controlpoints, -1, direction_index) b.roll(1) b.periodic -= 1 b.knots = b.knots[:-1] if periodic > b.periodic: - raise ValueError('Cannot raise periodicity') + raise ValueError("Cannot raise periodicity") return self - def set_dimension(self, new_dim): - """ Sets the physical dimension of the object. If increased, the new + def set_dimension(self, new_dim: int) -> Self: + """Set the physical dimension of the object. If increased, the new components are set to zero. :param int new_dim: New dimension. @@ -1069,14 +1261,13 @@ def set_dimension(self, new_dim): return self - def periodic(self, direction=0): - """Returns true if the spline object is periodic in the given parametric direction""" + def periodic(self, direction: Direction = 0) -> bool: + """Return true if the spline object is periodic in the given parametric direction.""" direction = check_direction(direction, self.pardim) - return self.bases[direction].periodic > -1 - def force_rational(self): - """ Force a rational representation of the object. + def force_rational(self) -> Self: + """Force a rational representation of the object. The weights of a non-rational object will be set to 1. @@ -1086,12 +1277,41 @@ def force_rational(self): dim = self.dimension shape = self.controlpoints.shape self.controlpoints = np.insert(self.controlpoints, dim, np.ones(shape[:-1]), self.pardim) - self.rational = 1 + self.rational = True return self - def split(self, knots, direction=0): - """ Split an object into two or more separate representations with C0 + def split_periodic(self, knot: Scalar, direction: Direction = 0) -> Self: + """Split a periodic object along one of its periodic axes. + + :param knot: The splitting knot + :type knot: float + :param direction: Parametric direction + :type direction: int + :return: The new object + :rtype: SplineObject + """ + + direction_index = check_direction(direction, self.pardim) + assert self.periodic(direction_index) + + splitting_obj = self.clone() + + continuity = splitting_obj.bases[direction_index].continuity(knot) + continuity = min(continuity, self.order(direction_index) - 1) + splitting_obj.insert_knot([float(knot)] * (continuity + 1), direction_index) + + basis = splitting_obj.bases[direction_index] + mu = bisect_left(basis.knots, knot) + basis.roll(mu) + splitting_obj.controlpoints = np.roll(splitting_obj.controlpoints, -mu, direction_index) + basis.knots = basis.knots[: -basis.periodic - 1] + basis.periodic = -1 + + return splitting_obj + + def split_nonperiodic(self, knot: ScalarOrScalars, direction: Direction = 0) -> list[Self]: + """Split an object into two or more separate representations with C0 continuity between them. :param knots: The splitting points @@ -1101,38 +1321,24 @@ def split(self, knots, direction=0): :return: The new objects :rtype: [SplineObject] """ + # for single-value input, wrap it into a list - knots = ensure_listlike(knots) + knots_list = ensure_scalars(knot) # error test input - direction = check_direction(direction, self.pardim) + direction_index = check_direction(direction, self.pardim) + assert not self.periodic(direction_index) - p = self.order(direction) - results = [] + p = self.order(direction_index) + results: list[Self] = [] splitting_obj = self.clone() bases = self.bases # insert knots to produce C{-1} at all splitting points - for k in knots: - continuity = bases[direction].continuity(k) + for k in knots_list: + continuity = bases[direction_index].continuity(k) if continuity == np.inf: continuity = p - 1 - splitting_obj.insert_knot([k] * (continuity + 1), direction) - - b = splitting_obj.bases[direction] - if b.periodic > -1: - mu = bisect_left(b.knots, knots[0]) - b.roll(mu) - splitting_obj.controlpoints = np.roll(splitting_obj.controlpoints, -mu, direction) - b.knots = b.knots[:-b.periodic-1] - b.periodic = -1 - if len(knots) > 1: - return splitting_obj.split(knots[1:], direction) - else: - return splitting_obj - - # search for the right subclass constructor, i.e. Volume, Surface or Curve - spline_object = [c for c in SplineObject.__subclasses__() if c._intended_pardim == len(bases)] - spline_object = spline_object[0] + splitting_obj.insert_knot([k] * (continuity + 1), direction_index) # everything is available now, just have to find the right index range # in the knot vector and controlpoints to store in each separate curve @@ -1141,36 +1347,61 @@ def split(self, knots, direction=0): last_knot_i = 0 bases = splitting_obj.bases - b = bases[direction] + b = bases[direction_index] cp_slice = [slice(None, None, None)] * len(self.controlpoints.shape) - for k in knots: - if self.start(direction) < k < self.end(direction): # skip start/end points + for k in knots_list: + if self.start(direction_index) < k < self.end(direction_index): # skip start/end points mu = bisect_left(b.knots, k) n_cp = mu - last_knot_i - knot_slice = slice(last_knot_i, mu+p, None) - cp_slice[direction] = slice(last_cp_i, last_cp_i+n_cp, None) + knot_slice = slice(last_knot_i, mu + p, None) + cp_slice[direction_index] = slice(last_cp_i, last_cp_i + n_cp, None) - cp = splitting_obj.controlpoints[ tuple(cp_slice) ] - bases[direction] = BSplineBasis(p, b.knots[knot_slice]) + cp = splitting_obj.controlpoints[tuple(cp_slice)] + bases[direction_index] = BSplineBasis(p, b.knots[knot_slice]) - args = bases + [cp, splitting_obj.rational] - results.append(spline_object(*args, raw=True)) + results.append(self.self_constructor(bases, cp, rational=splitting_obj.rational, raw=True)) last_knot_i = mu last_cp_i += n_cp # with n splitting points, we're getting n+1 pieces. Add the final one: - knot_slice = slice(last_knot_i, None, None) - cp_slice[direction] = slice(last_cp_i, None, None) - bases[direction] = BSplineBasis(p, b.knots[knot_slice]) - cp = splitting_obj.controlpoints[ tuple(cp_slice) ] - args = bases + [cp, splitting_obj.rational] - results.append(spline_object(*args, raw=True)) + knot_slice = slice(last_knot_i, None, None) + cp_slice[direction_index] = slice(last_cp_i, None, None) + bases[direction_index] = BSplineBasis(p, b.knots[knot_slice]) + cp = splitting_obj.controlpoints[tuple(cp_slice)] + results.append(self.self_constructor(bases, cp, rational=splitting_obj.rational, raw=True)) return results - def make_periodic(self, continuity=None, direction=0): - """ Make the spline object periodic in a given parametric direction. + def split(self, knots: ScalarOrScalars, direction: Direction = 0) -> Union[Self, list[Self]]: + """Split an object into two or more separate representations with C0 + continuity between them. + + :param knots: The splitting points + :type knots: float or [float] + :param direction: Parametric direction + :type direction: int + :return: The new objects + :rtype: [SplineObject] + """ + direction_index = check_direction(direction, self.pardim) + if self.periodic(direction_index): + knots_list = ensure_scalars(knots) + split_obj = self.split_periodic(knots_list[0], direction) + if len(knots_list) > 1: + return split_obj.split_nonperiodic(knots_list[1:], direction) + return split_obj + return self.split_nonperiodic(knots, direction) + + def split_many(self, knots: ScalarOrScalars, direction: Direction = 0) -> list[Self]: + """Like split, but always returns a list.""" + split_obj = self.split(knots, direction) + if isinstance(split_obj, list): + return split_obj + return [split_obj] + + def make_periodic(self, continuity: Optional[int] = None, direction: Direction = 0) -> Self: + """Make the spline object periodic in a given parametric direction. :param int continuity: The continuity along the boundary (default max). :param int direction: The direction to ensure continuity in. @@ -1181,25 +1412,22 @@ def make_periodic(self, continuity=None, direction=0): if continuity is None: continuity = basis.order - 2 if not -1 <= continuity <= basis.order - 2: - raise ValueError('Illegal continuity for basis of order {}: {}'.format( - continuity, basis.order - )) + raise ValueError(f"Illegal continuity for basis of order {continuity}: {basis.order}") if continuity == -1: raise ValueError( - 'Operation not supported. ' - 'For discontinuous spline spaces, consider SplineObject.split().' + "Operation not supported. For discontinuous spline spaces, consider SplineObject.split()." ) if basis.periodic >= 0: - raise ValueError('Basis is already periodic') + raise ValueError("Basis is already periodic") basis = basis.make_periodic(continuity) # Merge control points - index_beg = [slice(None,None,None)] * (self.pardim + 1) - index_end = [slice(None,None,None)] * (self.pardim + 1) + index_beg: list[Union[slice, int]] = [slice(None, None, None)] * (self.pardim + 1) + index_end: list[Union[slice, int]] = [slice(None, None, None)] * (self.pardim + 1) cps = np.array(self.controlpoints) - weights = np.linspace(0, 1, continuity + 1) if continuity > 0 else [0.5] - for i, j, t in zip(range(continuity + 1), range(-continuity-1, 0), weights): + weights = iter(np.linspace(0, 1, continuity + 1)) if continuity > 0 else iter([0.5]) + for i, j, t in zip(range(continuity + 1), range(-continuity - 1, 0), weights): # Weighted average between cps[..., i, ..., :] and cps[..., -c-1+i, ..., :] # The weights are chosen so that, for periodic c, the round trip # c.split().make_periodic() with suitable arguments produces an @@ -1214,34 +1442,30 @@ def make_periodic(self, continuity=None, direction=0): bases = list(self.bases) bases[direction] = basis - args = bases + [cps] + [self.rational] - # search for the right subclass constructor, i.e. Volume, Surface or Curve - constructor = [c for c in SplineObject.__subclasses__() if c._intended_pardim == len(self.bases)] - constructor = constructor[0] - return constructor(*args, raw=True) + return self.self_constructor(bases, cps, rational=self.rational, raw=True) @property - def pardim(self): - """ The number of parametric dimensions: 1 for curves, 2 for surfaces, 3 + def pardim(self) -> int: + """The number of parametric dimensions: 1 for curves, 2 for surfaces, 3 for volumes, etc. """ - return len(self.controlpoints.shape)-1 + return self.controlpoints.ndim - 1 - def clone(self): + def clone(self) -> Self: """Clone the object.""" return copy.deepcopy(self) __call__ = evaluate - def __len__(self): + def __len__(self) -> int: """Return the number of control points (basis functions) for the object.""" n = 1 for b in self.bases: n *= b.num_functions() return n - def _unravel_flat_index(self, i): + def _unravel_flat_index(self, i: Union[slice, SupportsIndex]) -> tuple[IPArray, ...]: """Unravels a flat index i to multi-indexes. :param i: Flat index @@ -1252,20 +1476,21 @@ def _unravel_flat_index(self, i): # i is int => make sure we deal with negative i properly # i is slice => use i.indices to compute the actual indices total = len(self) - if isinstance(i, int): - indexes = [i] if i >= 0 else [total + i] + if isinstance(i, SupportsIndex): + j = i.__index__() + indexes = [j] if j >= 0 else [total + j] else: indexes = list(range(*i.indices(total))) # Convert to multi-indexes try: - unraveled = np.unravel_index(indexes, self.controlpoints.shape[:-1], order='F') + unraveled = np.unravel_index(indexes, self.controlpoints.shape[:-1], order="F") except ValueError: raise IndexError return unraveled - def __getitem__(self, i): + def __getitem__(self, i: Union[slice, SupportsIndex, tuple[Union[slice, SupportsIndex], ...]]) -> FArray: """Get the control point at a given index. Indexing is in column-major order. Examples of supported indexing @@ -1294,10 +1519,14 @@ def __getitem__(self, i): # Singleton dimensions should be squeezed if the input was an int if isinstance(i, int): - return self.controlpoints[unraveled][0] - return self.controlpoints[unraveled] - - def __setitem__(self, i, cp): + return self.controlpoints[unraveled][0] # type: ignore[no-any-return] + return self.controlpoints[unraveled] # type: ignore[no-any-return] + + def __setitem__( + self, + i: Union[int, slice, SupportsIndex, tuple[Union[int, slice, SupportsIndex], ...]], + cp: ArrayLike, + ) -> None: """Set the control points at given indices. This function supports the same indexing modes as @@ -1314,57 +1543,88 @@ def __setitem__(self, i, cp): self.controlpoints[unraveled] = cp @property - def shape(self): + def shape(self) -> tuple[int, ...]: """The dimensions of the control point array.""" return self.controlpoints.shape[:-1] - def __iadd__(self, x): - self.translate(x) - return self + # TODO(Eivind): Py310 from types import NotImplementedType + # Then change all the return types to Self | NotImplementedType - def __isub__(self, x): - self.translate(-np.array(x)) # can't do -x if x is a list, so we rewrap it here - return self + def __iadd__(self, x: Any) -> Self: + if isinstance(x, (Sequence, np.ndarray)): + self.translate(x) + return self + return NotImplemented - def __imul__(self, x): - self.scale(x) - return self + def __isub__(self, x: Any) -> Self: + if isinstance(x, (Sequence, np.ndarray)): + self.translate(-np.array(x, dtype=float)) # can't do -x if x is a list, so we rewrap it here + return self + return NotImplemented - def __itruediv__(self, x): - self.scale(1.0 / x) - return self + def __imul__(self, x: Any) -> Self: + if isinstance(x, (Sequence, np.ndarray)): + self.scale(*x) + return self + if isinstance(x, float): + self.scale(x) + return self + if isinstance(x, SupportsFloat): + self.scale(float(x)) + return self + return NotImplemented + + def __itruediv__(self, x: Any) -> Self: + if isinstance(x, (Sequence, np.ndarray)): + y = 1 / np.ndarray(x, dtype=float) + self.scale(*y) + return self + if isinstance(x, float): + self.scale(1 / x) + return self + if isinstance(x, SupportsFloat): + self.scale(1 / float(x)) + return self + return NotImplemented __ifloordiv__ = __itruediv__ # integer division (should not distinguish) - __idiv__ = __itruediv__ # python2 compatibility - def __add__(self, x): - new_obj = copy.deepcopy(self) - new_obj += x - return new_obj + def __add__(self, x: Any) -> Self: + return self.clone().__iadd__(x) + + def __radd__(self, x: Any) -> Self: + return self.__add__(x) - def __radd__(self, x): - return self + x + def __sub__(self, x: Any) -> Self: + return self.clone().__isub__(x) - def __sub__(self, x): - new_obj = copy.deepcopy(self) - new_obj -= x - return new_obj + def __mul__(self, x: Any) -> Self: + return self.clone().__imul__(x) - def __mul__(self, x): - new_obj = copy.deepcopy(self) - new_obj *= x - return new_obj + def __rmul__(self, x: Any) -> Self: + return self.__mul__(x) - def __rmul__(self, x): - return self * x + def __div__(self, x: Any) -> Self: + return self.clone().__itruediv__(x) - def __div__(self, x): - new_obj = copy.deepcopy(self) - new_obj /= x - return new_obj + def flip_and_move_plane_geometry(self, center: Scalars = (0, 0, 0), normal: Scalars = (0, 0, 1)) -> Self: + """Re-orient a planar geometry by moving it to a different location and + tilting it. + + Don't call unless necessary. Translate or scale operations may force + an object into 3D space. + """ + if not np.allclose(np.asarray(normal), np.array([0, 0, 1])): + theta = np.arctan2(normal[1], normal[0]) + phi = np.arctan2(np.sqrt(normal[0] ** 2 + normal[1] ** 2), normal[2]) + self.rotate(phi, (0, 1, 0)) + self.rotate(theta, (0, 0, 1)) + if not np.allclose(np.asarray(center), 0): + self.translate(center) + return self @classmethod - def make_splines_compatible(cls, spline1, spline2): + def make_splines_compatible(cls, spline1: SplineObject, spline2: SplineObject) -> None: """Ensure that two splines are compatible. This will manipulate one or both to ensure that they are both rational @@ -1386,7 +1646,9 @@ def make_splines_compatible(cls, spline1, spline2): spline1.set_dimension(spline2.dimension) @classmethod - def make_splines_identical(cls, spline1, spline2, direction=None): + def make_splines_identical( + cls, spline1: SplineObject, spline2: SplineObject, direction: Optional[Direction] = None + ) -> None: """Ensure that two splines have identical discretization. This will first make them compatible (see @@ -1429,17 +1691,17 @@ def make_splines_identical(cls, spline1, spline2, direction=None): spline2.raise_order(p - p2, direction=i) # make sure both have the same knot vectors - knot1 = spline1.knots(direction=i) - knot2 = spline2.knots(direction=i) - b1 = spline1.bases[i] - b2 = spline2.bases[i] + knot1 = spline1.knots(i) + knot2 = spline2.knots(i) + b1 = spline1.bases[i] + b2 = spline2.bases[i] inserts = [] for k in knot1: c1 = b1.continuity(k) c2 = b2.continuity(k) if c2 > c1: - m = min(c2-c1, p-1-c1) # c2=np.inf if knot does not exist + m = min(c2 - c1, p - 1 - c1) # c2=np.inf if knot does not exist inserts.extend([k] * m) spline2.insert_knot(inserts, direction=i) @@ -1448,6 +1710,6 @@ def make_splines_identical(cls, spline1, spline2, direction=None): c1 = b1.continuity(k) c2 = b2.continuity(k) if c1 > c2: - m = min(c1-c2, p-1-c2) # c1=np.inf if knot does not exist - inserts.extend([k]*m) + m = min(c1 - c2, p - 1 - c2) # c1=np.inf if knot does not exist + inserts.extend([k] * m) spline1.insert_knot(inserts, direction=i) diff --git a/splipy/state.py b/splipy/state.py index 109f150..f028c7d 100644 --- a/splipy/state.py +++ b/splipy/state.py @@ -1,15 +1,20 @@ """This module handles the global Splipy state.""" -from contextlib import contextmanager import sys +from contextlib import contextmanager +from typing import Iterator, TypedDict -states = ['controlpoint_relative_tolerance', - 'controlpoint_absolute_tolerance', - 'parametric_relative_tolerance', - 'parametric_absolute_tolerance', - 'knot_tolerance', - 'unlimited'] -__all__ = states + ['state'] +from typing_extensions import Unpack + +states = [ + "controlpoint_relative_tolerance", + "controlpoint_absolute_tolerance", + "parametric_relative_tolerance", + "parametric_absolute_tolerance", + "knot_tolerance", + "unlimited", +] +__all__ = states + ["state"] controlpoint_absolute_tolerance = 1e-8 @@ -31,8 +36,17 @@ """Since splipy insists on finite parametric domains, we define 'unbounded' here""" +class StateKwargs(TypedDict, total=False): + controlpoint_absolute_tolerance: float + controlpoint_relative_tolerance: float + parametric_absolute_tolerance: float + parametric_relative_tolerance: float + knot_tolerance: float + unlimited: float + + @contextmanager -def state(**kwargs): +def state(**kwargs: Unpack[StateKwargs]) -> Iterator[None]: """A context manager for running code in a modified state. This takes an arbitrary number of keyword arguments, which correspond to @@ -58,8 +72,9 @@ def state(**kwargs): for k, v in kwargs.items(): setattr(module, k, v) - yield - - # Return settings to their previous values - for k, v in before.items(): - setattr(module, k, v) + try: + yield + finally: + # Return settings to their previous values + for k, v in before.items(): + setattr(module, k, v) diff --git a/splipy/surface.py b/splipy/surface.py index 22b74b0..a83f79f 100644 --- a/splipy/surface.py +++ b/splipy/surface.py @@ -1,16 +1,20 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations from bisect import bisect_left -from itertools import chain +from typing import TYPE_CHECKING, Any, Optional, Sequence, Union, cast import numpy as np from .basis import BSplineBasis from .curve import Curve -from .splineobject import SplineObject, evaluate -from .utils import is_singleton, ensure_listlike, check_direction, sections +from .splineobject import SplineObject, _evaluate +from .utils import check_direction, ensure_listlike, ensure_scalars, is_singleton, sections -__all__ = ['Surface'] +if TYPE_CHECKING: + from .types import Direction, FArray, Scalar, ScalarOrScalars + + +__all__ = ["Surface"] class Surface(SplineObject): @@ -20,8 +24,15 @@ class Surface(SplineObject): _intended_pardim = 2 - def __init__(self, basis1=None, basis2=None, controlpoints=None, rational=False, **kwargs): - """ Construct a surface with the given basis and control points. + def __init__( + self, + basis1: Optional[BSplineBasis] = None, + basis2: Optional[BSplineBasis] = None, + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> None: + """Construct a surface with the given basis and control points. The default is to create a linear one-element mapping from and to the unit square. @@ -33,10 +44,16 @@ def __init__(self, basis1=None, basis2=None, controlpoints=None, rational=False, control points are interpreted as pre-multiplied with the weight, which is the last coordinate) """ - super(Surface, self).__init__([basis1, basis2], controlpoints, rational, **kwargs) + super().__init__([basis1, basis2], controlpoints, rational=rational, raw=raw) - def normal(self, u, v, above=(True,True), tensor=True): - """ Evaluate the normal of the surface at given parametric values. + def normal( + self, + u: ScalarOrScalars, + v: ScalarOrScalars, + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> FArray: + """Evaluate the normal of the surface at given parametric values. This is equal to the cross-product between tangents. The return value is normalized. @@ -61,36 +78,45 @@ def normal(self, u, v, above=(True,True), tensor=True): :rtype: numpy.array :raises RuntimeError: If the physical dimension is not 2 or 3 """ + squeeze = all(is_singleton(t) for t in [u, v]) + u = ensure_scalars(u) + v = ensure_scalars(v) + if not tensor and len(u) != len(v): - raise ValueError('Parametes must have same length') + raise ValueError("Parametes must have same length") if self.dimension == 2: - try: - shape = (len(u), len(v), 3) if tensor else (len(u), 3) - result = np.zeros(shape) - result[..., 2] = 1 - return result - except TypeError: # single valued input u, fails on len(u) - return np.array([0, 0, 1]) - elif self.dimension == 3: + shape = (len(u), len(v), 3) if tensor else (len(u), 3) + result = np.zeros(shape) + result[..., 2] = 1 + return result[0, 0, ...] if squeeze else result + + if self.dimension == 3: # fetch the tangent vectors (du, dv) = self.tangent(u, v, above=above, tensor=tensor) # compute normals - normals = np.cross(du,dv) + normals = np.cross(du, dv) # normalize output if len(du.shape) == 1: return normals / np.linalg.norm(normals) - magnitude = np.linalg.norm( normals, axis=-1) + magnitude: FArray = np.linalg.norm(normals, axis=-1) magnitude = magnitude.reshape(magnitude.shape + (1,)) - return normals / magnitude - else: - raise RuntimeError('Normal evaluation only defined for 2D and 3D geometries') + retval = normals / magnitude + return retval[0, 0, ...] if squeeze else retval + raise RuntimeError("Normal evaluation only defined for 2D and 3D geometries") - def derivative(self, u, v, d=(1,1), above=True, tensor=True): - """ Evaluate the derivative of the surface at the given parametric values. + def derivative( # type: ignore[override] + self, + u: ScalarOrScalars, + v: ScalarOrScalars, + d: Union[int, Sequence[int]] = (1, 1), + above: Union[bool, Sequence[bool]] = True, + tensor: bool = True, + ) -> FArray: + """Evaluate the derivative of the surface at the given parametric values. This function returns an *n* × *m* x *dim* array, where *n* is the number of evaluation points in u, *m* is the number of evaluation points in v, and @@ -112,77 +138,110 @@ def derivative(self, u, v, d=(1,1), above=True, tensor=True): :rtype: numpy.array """ - squeeze = all(is_singleton(t) for t in [u,v]) + squeeze = all(is_singleton(t) for t in [u, v]) derivs = ensure_listlike(d, self.pardim) if not self.rational or np.sum(derivs) < 2 or np.sum(derivs) > 3: - return super(Surface, self).derivative(u,v, d=derivs, above=above, tensor=tensor) + return super().derivative(u, v, d=derivs, above=above, tensor=tensor) - u = ensure_listlike(u) - v = ensure_listlike(v) + above = ensure_listlike(above, dups=2) + u = ensure_scalars(u) + v = ensure_scalars(v) result = np.zeros((len(u), len(v), self.dimension)) # dNus = [self.bases[0].evaluate(u, d, above) for d in range(derivs[0]+1)] # dNvs = [self.bases[1].evaluate(v, d, above) for d in range(derivs[1]+1)] - dNus = [self.bases[0].evaluate(u, d, above) for d in range(np.sum(derivs)+1)] - dNvs = [self.bases[1].evaluate(v, d, above) for d in range(np.sum(derivs)+1)] - - d0ud0v = evaluate([dNus[0], dNvs[0]], self.controlpoints, tensor) - d1ud0v = evaluate([dNus[1], dNvs[0]], self.controlpoints, tensor) - d0ud1v = evaluate([dNus[0], dNvs[1]], self.controlpoints, tensor) - d1ud1v = evaluate([dNus[1], dNvs[1]], self.controlpoints, tensor) - d2ud0v = evaluate([dNus[2], dNvs[0]], self.controlpoints, tensor) - d0ud2v = evaluate([dNus[0], dNvs[2]], self.controlpoints, tensor) - W = d0ud0v[:,:,-1] - dWdu = d1ud0v[:,:,-1] - dWdv = d0ud1v[:,:,-1] - d2Wduv= d1ud1v[:,:,-1] - d2Wdu = d2ud0v[:,:,-1] - d2Wdv = d0ud2v[:,:,-1] + dNus = [self.bases[0].evaluate_dense(u, d=d, from_right=above[0]) for d in range(np.sum(derivs) + 1)] + dNvs = [self.bases[1].evaluate_dense(v, d=d, from_right=above[1]) for d in range(np.sum(derivs) + 1)] + + d0ud0v = _evaluate([dNus[0], dNvs[0]], self.controlpoints, tensor) + d1ud0v = _evaluate([dNus[1], dNvs[0]], self.controlpoints, tensor) + d0ud1v = _evaluate([dNus[0], dNvs[1]], self.controlpoints, tensor) + d1ud1v = _evaluate([dNus[1], dNvs[1]], self.controlpoints, tensor) + d2ud0v = _evaluate([dNus[2], dNvs[0]], self.controlpoints, tensor) + d0ud2v = _evaluate([dNus[0], dNvs[2]], self.controlpoints, tensor) + W = d0ud0v[:, :, -1] + dWdu = d1ud0v[:, :, -1] + dWdv = d0ud1v[:, :, -1] + d2Wduv = d1ud1v[:, :, -1] + d2Wdu = d2ud0v[:, :, -1] + d2Wdv = d0ud2v[:, :, -1] for i in range(self.dimension): - H1 = d1ud0v[:,:,i] * W - d0ud0v[:,:,i] * dWdu - H2 = d0ud1v[:,:,i] * W - d0ud0v[:,:,i] * dWdv - dH1du= d2ud0v[:,:,i] * W - d0ud0v[:,:,i] * d2Wdu - dH1dv= d1ud1v[:,:,i] * W + d1ud0v[:,:,i] * dWdv - d0ud1v[:,:,i] * dWdu - d0ud0v[:,:,i] * d2Wduv - dH2du= d1ud1v[:,:,i] * W + d0ud1v[:,:,i] * dWdu - d1ud0v[:,:,i] * dWdv - d0ud0v[:,:,i] * d2Wduv - dH2dv= d0ud2v[:,:,i] * W - d0ud0v[:,:,i] * d2Wdv - G1 = dH1du*W - 2*H1*dWdu - G2 = dH2dv*W - 2*H2*dWdv - if derivs == (1,0): - result[:,:,i] = H1 / W/W - elif derivs == (0,1): - result[:,:,i] = H2 / W/W - elif derivs == (1,1): - result[:,:,i] = (dH1dv*W - 2*H1*dWdv) /W/W/W - elif derivs == (2,0): - result[:,:,i] = G1 /W/W/W - elif derivs == (0,2): - result[:,:,i] = G2 /W/W/W + H1 = d1ud0v[:, :, i] * W - d0ud0v[:, :, i] * dWdu + H2 = d0ud1v[:, :, i] * W - d0ud0v[:, :, i] * dWdv + dH1du = d2ud0v[:, :, i] * W - d0ud0v[:, :, i] * d2Wdu + dH1dv = ( + d1ud1v[:, :, i] * W + + d1ud0v[:, :, i] * dWdv + - d0ud1v[:, :, i] * dWdu + - d0ud0v[:, :, i] * d2Wduv + ) + dH2du = ( + d1ud1v[:, :, i] * W + + d0ud1v[:, :, i] * dWdu + - d1ud0v[:, :, i] * dWdv + - d0ud0v[:, :, i] * d2Wduv + ) + dH2dv = d0ud2v[:, :, i] * W - d0ud0v[:, :, i] * d2Wdv + G1 = dH1du * W - 2 * H1 * dWdu + G2 = dH2dv * W - 2 * H2 * dWdv + + if derivs == [1, 0]: + result[:, :, i] = H1 / W / W + elif derivs == [0, 1]: + result[:, :, i] = H2 / W / W + elif derivs == [1, 1]: + result[:, :, i] = (dH1dv * W - 2 * H1 * dWdv) / W / W / W + elif derivs == [2, 0]: + result[:, :, i] = G1 / W / W / W + elif derivs == [0, 2]: + result[:, :, i] = G2 / W / W / W + if np.sum(derivs) > 2: - d2ud1v = evaluate([dNus[2], dNvs[1]], self.controlpoints, tensor) - d1ud2v = evaluate([dNus[1], dNvs[2]], self.controlpoints, tensor) - d3ud0v = evaluate([dNus[3], dNvs[0]], self.controlpoints, tensor) - d0ud3v = evaluate([dNus[0], dNvs[3]], self.controlpoints, tensor) - d3Wdu = d3ud0v[:,:,-1] - d3Wdv = d0ud3v[:,:,-1] - d3Wduuv = d2ud1v[:,:,-1] - d3Wduvv = d1ud2v[:,:,-1] - d2H1du = d3ud0v[:,:,i]*W + d2ud0v[:,:,i]*dWdu - d1ud0v[:,:,i]*d2Wdu - d0ud0v[:,:,i]*d3Wdu - d2H1duv = d2ud1v[:,:,i]*W + d2ud0v[:,:,i]*dWdv - d0ud1v[:,:,i]*d2Wdu - d0ud0v[:,:,i]*d3Wduuv - d2H2dv = d0ud3v[:,:,i]*W + d0ud2v[:,:,i]*dWdv - d0ud1v[:,:,i]*d2Wdv - d0ud0v[:,:,i]*d3Wdv - d2H2duv = d1ud2v[:,:,i]*W + d0ud2v[:,:,i]*dWdu - d1ud0v[:,:,i]*d2Wdv - d0ud0v[:,:,i]*d3Wduvv - dG1du = d2H1du *W + dH1du*dWdu - 2*dH1du*dWdu - 2*H1*d2Wdu - dG1dv = d2H1duv*W + dH1du*dWdv - 2*dH1dv*dWdu - 2*H1*d2Wduv - dG2du = d2H2duv*W + dH2dv*dWdu - 2*dH2du*dWdv - 2*H2*d2Wduv - dG2dv = d2H2dv *W + dH2dv*dWdv - 2*dH2dv*dWdv - 2*H2*d2Wdv - - if derivs == (3,0): - result[:,:,i] = (dG1du*W -3*G1*dWdu) /W/W/W/W - elif derivs == (0,3): - result[:,:,i] = (dG2dv*W -3*G2*dWdv) /W/W/W/W - elif derivs == (2,1): - result[:,:,i] = (dG1dv*W -3*G1*dWdv) /W/W/W/W - elif derivs == (1,2): - result[:,:,i] = (dG2du*W -3*G2*dWdu) /W/W/W/W + d2ud1v = _evaluate([dNus[2], dNvs[1]], self.controlpoints, tensor) + d1ud2v = _evaluate([dNus[1], dNvs[2]], self.controlpoints, tensor) + d3ud0v = _evaluate([dNus[3], dNvs[0]], self.controlpoints, tensor) + d0ud3v = _evaluate([dNus[0], dNvs[3]], self.controlpoints, tensor) + d3Wdu = d3ud0v[:, :, -1] + d3Wdv = d0ud3v[:, :, -1] + d3Wduuv = d2ud1v[:, :, -1] + d3Wduvv = d1ud2v[:, :, -1] + d2H1du = ( + d3ud0v[:, :, i] * W + + d2ud0v[:, :, i] * dWdu + - d1ud0v[:, :, i] * d2Wdu + - d0ud0v[:, :, i] * d3Wdu + ) + d2H1duv = ( + d2ud1v[:, :, i] * W + + d2ud0v[:, :, i] * dWdv + - d0ud1v[:, :, i] * d2Wdu + - d0ud0v[:, :, i] * d3Wduuv + ) + d2H2dv = ( + d0ud3v[:, :, i] * W + + d0ud2v[:, :, i] * dWdv + - d0ud1v[:, :, i] * d2Wdv + - d0ud0v[:, :, i] * d3Wdv + ) + d2H2duv = ( + d1ud2v[:, :, i] * W + + d0ud2v[:, :, i] * dWdu + - d1ud0v[:, :, i] * d2Wdv + - d0ud0v[:, :, i] * d3Wduvv + ) + dG1du = d2H1du * W + dH1du * dWdu - 2 * dH1du * dWdu - 2 * H1 * d2Wdu + dG1dv = d2H1duv * W + dH1du * dWdv - 2 * dH1dv * dWdu - 2 * H1 * d2Wduv + dG2du = d2H2duv * W + dH2dv * dWdu - 2 * dH2du * dWdv - 2 * H2 * d2Wduv + dG2dv = d2H2dv * W + dH2dv * dWdv - 2 * dH2dv * dWdv - 2 * H2 * d2Wdv + + if derivs == [3, 0]: + result[:, :, i] = (dG1du * W - 3 * G1 * dWdu) / W / W / W / W + elif derivs == [0, 3]: + result[:, :, i] = (dG2dv * W - 3 * G2 * dWdv) / W / W / W / W + elif derivs == [2, 1]: + result[:, :, i] = (dG1dv * W - 3 * G1 * dWdv) / W / W / W / W + elif derivs == [1, 2]: + result[:, :, i] = (dG2du * W - 3 * G2 * dWdu) / W / W / W / W # Squeeze the singleton dimensions if we only have one point if squeeze: @@ -190,47 +249,47 @@ def derivative(self, u, v, d=(1,1), above=True, tensor=True): return result + def area(self) -> float: + """Compute the area of the surface in geometric space.""" - def area(self): - """ Computes the area of the surface in geometric space """ # fetch integration points - (x1,w1) = np.polynomial.legendre.leggauss(self.order(0)+1) - (x2,w2) = np.polynomial.legendre.leggauss(self.order(1)+1) + x1, wt1 = np.polynomial.legendre.leggauss(self.order(0) + 1) + x2, wt2 = np.polynomial.legendre.leggauss(self.order(1) + 1) + # map points to parametric coordinates (and update the weights) - (knots1,knots2) = self.knots() - u = np.array([ (x1+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots1[:-1], knots1[1:]) ]) - w1 = np.array([ w1/2*(t1-t0) for t0,t1 in zip(knots1[:-1], knots1[1:]) ]) - v = np.array([ (x2+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots2[:-1], knots2[1:]) ]) - w2 = np.array([ w2/2*(t1-t0) for t0,t1 in zip(knots2[:-1], knots2[1:]) ]) - - # wrap everything to vectors - u = np.ndarray.flatten(u) - v = np.ndarray.flatten(v) - w1 = np.ndarray.flatten(w1) - w2 = np.ndarray.flatten(w2) + knots1, knots2 = self.knots() + u = np.array( + [(x1 + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots1[:-1], knots1[1:])], dtype=float + ).flatten() + w1 = np.array([wt1 / 2 * (t1 - t0) for t0, t1 in zip(knots1[:-1], knots1[1:])], dtype=float).flatten() + v = np.array( + [(x2 + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots2[:-1], knots2[1:])], dtype=float + ).flatten() + w2 = np.array([wt2 / 2 * (t1 - t0) for t0, t1 in zip(knots2[:-1], knots2[1:])], dtype=float).flatten() # compute all quantities of interest (i.e. the jacobian) - du = self.derivative(u,v, d=(1,0)) - dv = self.derivative(u,v, d=(0,1)) - J = np.cross(du,dv) + du = self.derivative(u, v, d=(1, 0)) + dv = self.derivative(u, v, d=(0, 1)) + J = np.cross(du, dv) - if self.dimension == 3: - J = np.sqrt(np.sum(J**2, axis=2)) - else: - J = np.abs(J) - return w1.dot(J).dot(w2) + J = np.sqrt(np.sum(J**2, axis=2)) if self.dimension == 3 else np.abs(J) + return cast(float, w1.dot(J).dot(w2)) - def edges(self): + def edges(self) -> tuple[Curve, Curve, Curve, Curve]: """Return the four edge curves in (parametric) order: umin, umax, vmin, vmax :return: Edge curves :rtype: (Curve) """ - return tuple(self.section(*args) for args in sections(2, 1)) + return cast( + tuple[Curve, Curve, Curve, Curve], + tuple(self.section(*args) for args in sections(2, 1)), + ) - def const_par_curve(self, knot, direction): - """ Get a Curve representation of the parametric line of some constant + def const_par_curve(self, knot: Scalar, direction: Direction) -> Curve: + """Get a Curve representation of the parametric line of some constant knot value. + :param float knot: The constant knot value to sample the surface :param int direction: The parametric direction for the constant value :return: curve on this surface @@ -239,23 +298,27 @@ def const_par_curve(self, knot, direction): direction = check_direction(direction, 2) # clone basis since we need to augment this by knot insertion - b = self.bases[direction].clone() + b = self.bases[direction].clone() # compute mapping matrix C which is the knotinsertion operator - mult = min(b.continuity(knot), b.order-1) - C = np.identity(self.shape[direction]) + mult = min(b.continuity(knot), b.order - 1) + C = np.identity(self.shape[direction]) for i in range(mult): C = b.insert_knot(knot) @ C # at this point we have a C0 basis, find the right interpolating index - i = max(bisect_left(b.knots, knot) - 1,0) + i = max(bisect_left(b.knots, knot) - 1, 0) # compute the controlpoints and return Curve - cp = np.tensordot(C[i,:], self.controlpoints, axes=(0, direction)) - return Curve(self.bases[1-direction], cp, self.rational) - - def rebuild(self, p, n): - """ Creates an approximation to this surface by resampling it using + cp = np.tensordot(C[i, :], self.controlpoints, axes=(0, direction)) + return Curve(self.bases[1 - direction], cp, self.rational) + + def rebuild( + self, + p: Union[int, Sequence[int]], + n: Union[int, Sequence[int]], + ) -> Surface: + """Create an approximation to this surface by resampling it using uniform knot vectors of order *p* with *n* control points. :param (int) p: Tuple of polynomial discretization order in each direction @@ -279,7 +342,7 @@ def rebuild(self, p, n): basis[i].normalize() t0 = old_basis[i].start() t1 = old_basis[i].end() - basis[i] *= (t1 - t0) + basis[i] *= t1 - t0 basis[i] += t0 # fetch evaluation points and evaluate basis functions @@ -300,13 +363,13 @@ def rebuild(self, p, n): # return new resampled curve return Surface(basis[0], basis[1], cp) - def __repr__(self): - result = str(self.bases[0]) + '\n' + str(self.bases[1]) + '\n' + def __repr__(self) -> str: + result = str(self.bases[0]) + "\n" + str(self.bases[1]) + "\n" # print legacy controlpoint enumeration - n1, n2, n3 = self.controlpoints.shape + n1, n2, _ = self.controlpoints.shape for j in range(n2): for i in range(n1): - result += str(self.controlpoints[i, j, :]) + '\n' + result += str(self.controlpoints[i, j, :]) + "\n" return result get_derivative_surface = SplineObject.get_derivative_spline diff --git a/splipy/surface_factory.py b/splipy/surface_factory.py index 91a7998..780b119 100644 --- a/splipy/surface_factory.py +++ b/splipy/surface_factory.py @@ -1,27 +1,50 @@ -# -*- coding: utf-8 -*- - """Handy utilities for creating surfaces.""" -from math import pi, sqrt, atan2 +from __future__ import annotations + import inspect -import os -from os.path import dirname, realpath, join +from itertools import chain, repeat +from math import atan2, pi, sqrt +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Literal, Optional, Sequence, Union, cast, overload import numpy as np +from . import curve_factory, state from .basis import BSplineBasis from .curve import Curve from .surface import Surface -from .utils import flip_and_move_plane_geometry, rotate_local_x_axis -from .utils.nutils import controlpoints, multiplicities, degree -from . import curve_factory, state - -__all__ = ['square', 'disc', 'sphere', 'extrude', 'revolve', 'cylinder', 'torus', 'edge_curves', - 'thicken', 'sweep', 'loft', 'interpolate', 'least_square_fit', 'teapot'] - - -def square(size=1, lower_left=(0,0)): - """ Create a square with parametric origin at *(0,0)*. +from .utils import rotate_local_x_axis +from .utils.curve import curve_length_parametrization +from .utils.nutils import controlpoints, degree, multiplicities + +if TYPE_CHECKING: + from .types import FArray, Scalar, Scalars + + +__all__ = [ + "square", + "disc", + "sphere", + "extrude", + "revolve", + "cylinder", + "torus", + "edge_curves", + "thicken", + "sweep", + "loft", + "interpolate", + "least_square_fit", + "teapot", +] + + +def square( + size: Union[Scalar, tuple[Scalar, Scalar]] = 1, + lower_left: Scalars = (0, 0), +) -> Surface: + """Create a square with parametric origin at *(0,0)*. :param float size: Size(s), either a single scalar or a tuple of scalars per axis :param array-like lower_left: local origin, the lower left corner of the square @@ -29,13 +52,22 @@ def square(size=1, lower_left=(0,0)): :rtype: Surface """ result = Surface() # unit square - result.scale(size) + if isinstance(size, tuple): + result.scale(*size) + else: + result.scale(size) result += lower_left return result -def disc(r=1, center=(0,0,0), normal=(0,0,1), type='radial', xaxis=(1,0,0)): - """ Create a circular disc. The *type* parameter distinguishes between +def disc( + r: Scalar = 1, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + type: Literal["radial", "square"] = "radial", + xaxis: Scalars = (1, 0, 0), +) -> Surface: + """Create a circular disc. The *type* parameter distinguishes between different parametrizations. :param float r: Radius @@ -46,33 +78,45 @@ def disc(r=1, center=(0,0,0), normal=(0,0,1), type='radial', xaxis=(1,0,0)): :return: The disc :rtype: Surface """ - if type == 'radial': + if type == "radial": c1 = curve_factory.circle(r, center=center, normal=normal, xaxis=xaxis) - c2 = flip_and_move_plane_geometry(c1*0, center, normal) + c2 = (c1 * 0).flip_and_move_plane_geometry(center, normal) result = edge_curves(c2, c1) result.swap() - result.reparam((0,r), (0,2*pi)) + result.reparam((0, r), (0, 2 * pi)) return result - elif type == 'square': + + if type == "square": w = 1 / sqrt(2) - cp = [[-r * w, -r * w, 1], - [0, -r, w], - [r * w, -r * w, 1], - [-r, 0, w], - [0, 0, 1], - [r, 0, w], - [-r * w, r * w, 1], - [0, r, w], - [r * w, r * w, 1]] + cp = np.array( + [ + [-r * w, -r * w, 1], + [0, -r, w], + [r * w, -r * w, 1], + [-r, 0, w], + [0, 0, 1], + [r, 0, w], + [-r * w, r * w, 1], + [0, r, w], + [r * w, r * w, 1], + ], + dtype=float, + ) basis1 = BSplineBasis(3) basis2 = BSplineBasis(3) result = Surface(basis1, basis2, cp, True) - return flip_and_move_plane_geometry(result, center, normal) - else: - raise ValueError('invalid type argument') + return result.flip_and_move_plane_geometry(center, normal) + + raise ValueError("invalid type argument") -def sphere(r=1, center=(0,0,0), zaxis=(0,0,1), xaxis=(1,0,0)): - """ Create a spherical shell. + +def sphere( + r: Scalar = 1, + center: Scalars = (0, 0, 0), + zaxis: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), +) -> Surface: + """Create a spherical shell. :param float r: Radius :param array-like center: Local origin of the sphere @@ -87,11 +131,11 @@ def sphere(r=1, center=(0,0,0), zaxis=(0,0,1), xaxis=(1,0,0)): result = revolve(circle) result.rotate(rotate_local_x_axis(xaxis, zaxis)) - return flip_and_move_plane_geometry(result, center, zaxis) + return result.flip_and_move_plane_geometry(center, zaxis) -def extrude(curve, amount): - """ Extrude a curve by sweeping it to a given height. +def extrude(curve: Curve, amount: Scalars) -> Surface: + """Extrude a curve by sweeping it to a given height. :param Curve curve: Curve to extrude :param array-like amount: 3-component vector of sweeping amount and @@ -102,15 +146,19 @@ def extrude(curve, amount): curve = curve.clone() # clone input curve, throw away input reference curve.set_dimension(3) # add z-components (if not already present) n = len(curve) # number of control points of the curve - cp = np.zeros((2 * n, curve.dimension + curve.rational)) + cp = np.zeros((2 * n, curve.dimension + curve.rational), dtype=float) cp[:n, :] = curve.controlpoints # the first control points form the bottom curve += amount cp[n:, :] = curve.controlpoints # the last control points form the top - return Surface(curve.bases[0], BSplineBasis(2), cp, curve.rational) + return Surface(curve.bases[0], BSplineBasis(2), cp, rational=curve.rational) -def revolve(curve, theta=2 * pi, axis=(0,0,1)): - """ Revolve a surface by sweeping a curve in a rotational fashion around +def revolve( + curve: Curve, + theta: Scalar = 2 * pi, + axis: Scalars = (0, 0, 1), +) -> Surface: + """Revolve a surface by sweeping a curve in a rotational fashion around the *z* axis. :param Curve curve: Curve to revolve @@ -125,37 +173,43 @@ def revolve(curve, theta=2 * pi, axis=(0,0,1)): # align axis with the z-axis normal_theta = atan2(axis[1], axis[0]) - normal_phi = atan2(sqrt(axis[0]**2 + axis[1]**2), axis[2]) - curve.rotate(-normal_theta, [0,0,1]) - curve.rotate(-normal_phi, [0,1,0]) + normal_phi = atan2(sqrt(axis[0] ** 2 + axis[1] ** 2), axis[2]) + curve.rotate(-normal_theta, [0, 0, 1]) + curve.rotate(-normal_phi, [0, 1, 0]) circle_seg = curve_factory.circle_segment(theta) - n = len(curve) # number of control points of the curve - m = len(circle_seg) # number of control points of the sweep + n = len(curve) # number of control points of the curve + m = len(circle_seg) # number of control points of the sweep cp = np.zeros((m * n, 4)) # loop around the circle and set control points by the traditional 9-point # circle curve with weights 1/sqrt(2), only here C0-periodic, so 8 points - dt = 0 - t = 0 + dt = 0.0 + t = 0.0 for i in range(m): - x,y,w = circle_seg[i] - dt = atan2(y,x) - t - t += dt + x, y, w = circle_seg[i] + dt = atan2(y, x) - t + t += dt curve.rotate(dt) - cp[i * n:(i + 1) * n, :] = curve[:] - cp[i * n:(i + 1) * n, 2] *= w - cp[i * n:(i + 1) * n, 3] *= w + cp[i * n : (i + 1) * n, :] = curve[:] + cp[i * n : (i + 1) * n, 2] *= w + cp[i * n : (i + 1) * n, 3] *= w result = Surface(curve.bases[0], circle_seg.bases[0], cp, True) # rotate it back again - result.rotate(normal_phi, [0,1,0]) - result.rotate(normal_theta, [0,0,1]) + result.rotate(normal_phi, (0, 1, 0)) + result.rotate(normal_theta, (0, 0, 1)) return result -def cylinder(r=1, h=1, center=(0,0,0), axis=(0,0,1), xaxis=(1,0,0)): - """ Create a cylinder shell with no top or bottom +def cylinder( + r: Scalar = 1, + h: Scalar = 1, + center: Scalars = (0, 0, 0), + axis: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), +) -> Surface: + """Create a cylinder shell with no top or bottom :param float r: Radius :param float h: Height @@ -165,11 +219,17 @@ def cylinder(r=1, h=1, center=(0,0,0), axis=(0,0,1), xaxis=(1,0,0)): :return: The cylinder shell :rtype: Surface """ - return extrude(curve_factory.circle(r, center, axis, xaxis=xaxis), h*np.array(axis)) + return extrude(curve_factory.circle(r, center, axis, xaxis=xaxis), h * np.array(axis)) -def torus(minor_r=1, major_r=3, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0)): - """ Create a torus (doughnut) by revolving a circle of size *minor_r* +def torus( + minor_r: Scalar = 1, + major_r: Scalar = 3, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), +) -> Surface: + """Create a torus (doughnut) by revolving a circle of size *minor_r* around the *z* axis with radius *major_r*. :param float minor_r: The thickness of the torus (radius in the *xz* plane) @@ -182,28 +242,48 @@ def torus(minor_r=1, major_r=3, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0)): """ circle = curve_factory.circle(minor_r) circle.rotate(pi / 2, (1, 0, 0)) # flip up into xz-plane - circle.translate((major_r, 0, 0)) # move into position to spin around z-axis + circle.translate((float(major_r), 0.0, 0.0)) # move into position to spin around z-axis result = revolve(circle) result.rotate(rotate_local_x_axis(xaxis, normal)) - return flip_and_move_plane_geometry(result, center, normal) + return result.flip_and_move_plane_geometry(center, normal) + + +@overload +def edge_curves( + curves: Sequence[Curve], + type: Literal["coons", "poissson", "elasticity", "finitestrain"] = "coons", +) -> Surface: + ... + + +@overload +def edge_curves( + *curves: Curve, + type: Literal["coons", "poissson", "elasticity", "finitestrain"] = "coons", +) -> Surface: + ... -def edge_curves(*curves, **kwargs): - """ Create the surface defined by the region between the input curves. +def edge_curves( # type: ignore[misc] + *curves: Curve, + type: Literal["coons", "poissson", "elasticity", "finitestrain"] = "coons", +) -> Surface: + """Create the surface defined by the region between the input curves. In case of four input curves, these must be given in an ordered directional closed loop around the resulting surface. :param [Curve] curves: Two or four edge curves - :param string type: The method used for interior computation ('coons', 'poisson', 'elasticity' or 'finitestrain') + :param string type: The method used for interior computation + ('coons', 'poisson', 'elasticity' or 'finitestrain') :return: The enclosed surface :rtype: Surface :raises ValueError: If the length of *curves* is not two or four """ - type = kwargs.get('type', 'coons') - if len(curves) == 1: # probably gives input as a list-like single variable - curves = curves[0] + if len(curves) == 1: # probably gives input as a list-like single variable + curves = cast(tuple[Curve], curves[0]) + if len(curves) == 2: crv1 = curves[0].clone() crv2 = curves[1].clone() @@ -216,55 +296,58 @@ def edge_curves(*curves, **kwargs): linear = BSplineBasis(2) return Surface(crv1.bases[0], linear, controlpoints, crv1.rational) - elif len(curves) == 4: + + if len(curves) == 4: # reorganize input curves so they form a directed loop around surface rtol = state.controlpoint_relative_tolerance atol = state.controlpoint_absolute_tolerance - mycurves = [c.clone() for c in curves] # wrap into list and clone all since we're changing them - dim = np.max([c.dimension for c in mycurves]) - rat = np.any([c.rational for c in mycurves]) + mycurves = [c.clone() for c in curves] # wrap into list and clone all since we're changing them + # dim = np.max([c.dimension for c in mycurves]) + # rat = np.any([c.rational for c in mycurves]) for i in range(4): - for j in range(i+1,4): + for j in range(i + 1, 4): Curve.make_splines_compatible(mycurves[i], mycurves[j]) - if not (np.allclose(mycurves[0][-1], mycurves[1][0], rtol=rtol, atol=atol) and - np.allclose(mycurves[1][-1], mycurves[2][0], rtol=rtol, atol=atol) and - np.allclose(mycurves[2][-1], mycurves[3][0], rtol=rtol, atol=atol) and - np.allclose(mycurves[3][-1], mycurves[0][0], rtol=rtol, atol=atol)): + if not ( + np.allclose(mycurves[0][-1], mycurves[1][0], rtol=rtol, atol=atol) + and np.allclose(mycurves[1][-1], mycurves[2][0], rtol=rtol, atol=atol) + and np.allclose(mycurves[2][-1], mycurves[3][0], rtol=rtol, atol=atol) + and np.allclose(mycurves[3][-1], mycurves[0][0], rtol=rtol, atol=atol) + ): reorder = [mycurves[0]] del mycurves[0] for j in range(3): found_match = False for i in range(len(mycurves)): - if(np.allclose(reorder[j][-1], mycurves[i][0], rtol=rtol, atol=atol)): + if np.allclose(reorder[j][-1], mycurves[i][0], rtol=rtol, atol=atol): reorder.append(mycurves[i]) del mycurves[i] found_match = True break - elif(np.allclose(reorder[j][-1], mycurves[i][-1], rtol=rtol, atol=atol)): + if np.allclose(reorder[j][-1], mycurves[i][-1], rtol=rtol, atol=atol): reorder.append(mycurves[i].reverse()) del mycurves[i] found_match = True break if not found_match: - raise RuntimeError('Curves do not form a closed loop (end-points do not match)') + raise RuntimeError("Curves do not form a closed loop (end-points do not match)") mycurves = reorder - if type == 'coons': + + if type == "coons": return coons_patch(*mycurves) - elif type == 'poisson': + if type == "poisson": return poisson_patch(*mycurves) - elif type == 'elasticity': + if type == "elasticity": return elasticity_patch(*mycurves) - elif type == 'finitestrain': + if type == "finitestrain": return finitestrain_patch(*mycurves) - else: - raise ValueError('Unknown type parameter') - else: - raise ValueError('Requires two or four input curves') + raise ValueError("Unknown type parameter") + raise ValueError("Requires two or four input curves") -def coons_patch(bottom, right, top, left): - """ Create the surface defined by the region between the 4 input curves. + +def coons_patch(bottom: Curve, right: Curve, top: Curve, left: Curve) -> Surface: + """Create the surface defined by the region between the 4 input curves. The input curves need to be parametrized to form a directed loop around the resulting Surface. For more information on Coons patch see: https://en.wikipedia.org/wiki/Coons_patch. @@ -288,8 +371,8 @@ def coons_patch(bottom, right, top, left): linear = BSplineBasis(2) rat = s1.rational # using control-points from top/bottom, so need to know if these are rational if rat: - bottom = bottom.clone().force_rational() # don't mess with the input curve, make clone - top.force_rational() # this is already a clone + bottom = bottom.clone().force_rational() # don't mess with the input curve, make clone + top.force_rational() # this is already a clone s3 = Surface(linear, linear, [bottom[0], bottom[-1], top[0], top[-1]], rat) # in order to add spline surfaces, they need identical parametrization @@ -303,17 +386,21 @@ def coons_patch(bottom, right, top, left): return result -def poisson_patch(bottom, right, top, left): +def poisson_patch(bottom: Curve, right: Curve, top: Curve, left: Curve) -> Surface: from nutils import version + if int(version[0]) != 4: - raise ImportError('Mismatching nutils version detected, only version 4 supported. Upgrade by \"pip install --upgrade nutils\"') + raise ImportError( + "Mismatching nutils version detected, only version 4 supported. " + 'Upgrade by "pip install --upgrade nutils"' + ) - from nutils import mesh, function as fn - from nutils import _, log + from nutils import function as fn + from nutils import mesh # error test input if left.rational or right.rational or top.rational or bottom.rational: - raise RuntimeError('poisson_patch not supported for rational splines') + raise RuntimeError("poisson_patch not supported for rational splines") # these are given as a oriented loop, so make all run in positive parametric direction top.reverse() @@ -328,55 +415,58 @@ def poisson_patch(bottom, right, top, left): p2 = left.order(0) n1 = len(bottom) n2 = len(left) - dim= left.dimension + dim = left.dimension k1 = bottom.knots(0) k2 = left.knots(0) m1 = [bottom.order(0) - bottom.continuity(k) - 1 for k in k1] - m2 = [left.order(0) - left.continuity(k) - 1 for k in k2] + m2 = [left.order(0) - left.continuity(k) - 1 for k in k2] domain, geom = mesh.rectilinear([k1, k2]) - basis = domain.basis('spline', [p1-1, p2-1], knotmultiplicities=[m1,m2]) + basis = domain.basis("spline", [p1 - 1, p2 - 1], knotmultiplicities=[m1, m2]) # assemble system matrix - grad = basis.grad(geom) - outer = fn.outer(grad,grad) + grad = basis.grad(geom) + outer = fn.outer(grad, grad) integrand = outer.sum(-1) - matrix = domain.integrate(integrand * fn.J(geom), ischeme='gauss'+str(max(p1,p2)+1)) + matrix = domain.integrate(integrand * fn.J(geom), ischeme="gauss" + str(max(p1, p2) + 1)) # initialize variables - controlpoints = np.zeros((n1,n2,dim)) - rhs = np.zeros((n1*n2)) - constraints = np.array([[np.nan]*n2]*n1) + controlpoints = np.zeros((n1, n2, dim)) + rhs = np.zeros(n1 * n2) + constraints = np.array([[np.nan] * n2] * n1) # treat all dimensions independently for d in range(dim): # add boundary conditions - constraints[ 0, :] = left[ :,d] - constraints[-1, :] = right[ :,d] - constraints[ :, 0] = bottom[:,d] - constraints[ :,-1] = top[ :,d] + constraints[0, :] = left[:, d] + constraints[-1, :] = right[:, d] + constraints[:, 0] = bottom[:, d] + constraints[:, -1] = top[:, d] # solve system lhs = matrix.solve(rhs, constrain=np.ndarray.flatten(constraints)) # wrap results into splipy datastructures - controlpoints[:,:,d] = np.reshape(lhs, (n1,n2), order='C') + controlpoints[:, :, d] = np.reshape(lhs, (n1, n2), order="C") return Surface(bottom.bases[0], left.bases[0], controlpoints, bottom.rational, raw=True) -def elasticity_patch(bottom, right, top, left): +def elasticity_patch(bottom: Curve, right: Curve, top: Curve, left: Curve) -> Surface: from nutils import version + if int(version[0]) != 4: - raise ImportError('Mismatching nutils version detected, only version 4 supported. Upgrade by \"pip install --upgrade nutils\"') + raise ImportError( + "Mismatching nutils version detected, only version 4 supported. " + 'Upgrade by "pip install --upgrade nutils"' + ) - from nutils import mesh, function, solver - from nutils import _, log + from nutils import function, mesh # error test input if not (left.dimension == right.dimension == top.dimension == bottom.dimension == 2): - raise RuntimeError('elasticity_patch only supported for planar (2D) geometries') + raise RuntimeError("elasticity_patch only supported for planar (2D) geometries") if left.rational or right.rational or top.rational or bottom.rational: - raise RuntimeError('elasticity_patch not supported for rational splines') + raise RuntimeError("elasticity_patch not supported for rational splines") # these are given as a oriented loop, so make all run in positive parametric direction top.reverse() @@ -391,62 +481,65 @@ def elasticity_patch(bottom, right, top, left): p2 = left.order(0) n1 = len(bottom) n2 = len(left) - dim= left.dimension + dim = left.dimension k1 = bottom.knots(0) k2 = left.knots(0) m1 = [bottom.order(0) - bottom.continuity(k) - 1 for k in k1] - m2 = [left.order(0) - left.continuity(k) - 1 for k in k2] + m2 = [left.order(0) - left.continuity(k) - 1 for k in k2] domain, geom = mesh.rectilinear([k1, k2]) # assemble system matrix ns = function.Namespace() ns.x = geom - ns.basis = domain.basis('spline', degree=[p1-1,p2-1], knotmultiplicities=[m1,m2]).vector(2) - ns.eye = np.array([[1,0],[0,1]]) + ns.basis = domain.basis("spline", degree=[p1 - 1, p2 - 1], knotmultiplicities=[m1, m2]).vector(2) + ns.eye = np.array([[1, 0], [0, 1]]) ns.lmbda = 1 - ns.mu = .3 + ns.mu = 0.3 # ns.u_i = 'basis_ni ?lhs_n' # ns.strain_ij = '(u_i,j + u_j,i) / 2' # ns.stress_ij = 'lmbda strain_kk eye_ij + 2 mu strain_ij' - ns.strain_nij = '(basis_ni,j + basis_nj,i) / 2' - ns.stress_nij = 'lmbda strain_nkk eye_ij + 2 mu strain_nij' + ns.strain_nij = "(basis_ni,j + basis_nj,i) / 2" + ns.stress_nij = "lmbda strain_nkk eye_ij + 2 mu strain_nij" # construct matrix and right hand-side - matrix = domain.integrate(ns.eval_nm('strain_nij stress_mij d:x'), ischeme='gauss'+str(max(p1,p2)+1)) - rhs = np.zeros((n1*n2*dim)) + matrix = domain.integrate(ns.eval_nm("strain_nij stress_mij d:x"), ischeme="gauss" + str(max(p1, p2) + 1)) + rhs = np.zeros((n1 * n2 * dim,), dtype=float) # add boundary conditions - constraints = np.array([[[np.nan]*n2]*n1]*dim) + constraints = np.array([[[np.nan] * n2] * n1] * dim) for d in range(dim): - constraints[d, 0, :] = left[ :,d] - constraints[d,-1, :] = right[ :,d] - constraints[d, :, 0] = bottom[:,d] - constraints[d, :,-1] = top[ :,d] + constraints[d, 0, :] = left[:, d] + constraints[d, -1, :] = right[:, d] + constraints[d, :, 0] = bottom[:, d] + constraints[d, :, -1] = top[:, d] # solve system - lhs = matrix.solve(rhs, constrain=np.ndarray.flatten(constraints, order='C')) + lhs = matrix.solve(rhs, constrain=np.ndarray.flatten(constraints, order="C")) # rewrap results into splipy datastructures - controlpoints = np.reshape(lhs, (dim,n1,n2), order='C') - controlpoints = controlpoints.swapaxes(0,2) - controlpoints = controlpoints.swapaxes(0,1) + controlpoints = np.reshape(lhs, (dim, n1, n2), order="C") + controlpoints = controlpoints.swapaxes(0, 2) + controlpoints = controlpoints.swapaxes(0, 1) return Surface(bottom.bases[0], left.bases[0], controlpoints, bottom.rational, raw=True) -def finitestrain_patch(bottom, right, top, left): +def finitestrain_patch(bottom: Curve, right: Curve, top: Curve, left: Curve) -> Surface: from nutils import version + if int(version[0]) != 4: - raise ImportError('Mismatching nutils version detected, only version 4 supported. Upgrade by \"pip install --upgrade nutils\"') + raise ImportError( + "Mismatching nutils version detected, only version 4 supported. " + 'Upgrade by "pip install --upgrade nutils"' + ) - from nutils import mesh, function - from nutils import _, log, solver + from nutils import function, mesh, solver # error test input if not (left.dimension == right.dimension == top.dimension == bottom.dimension == 2): - raise RuntimeError('finitestrain_patch only supported for planar (2D) geometries') + raise RuntimeError("finitestrain_patch only supported for planar (2D) geometries") if left.rational or right.rational or top.rational or bottom.rational: - raise RuntimeError('finitestrain_patch not supported for rational splines') + raise RuntimeError("finitestrain_patch not supported for rational splines") # these are given as a oriented loop, so make all run in positive parametric direction top.reverse() @@ -459,10 +552,10 @@ def finitestrain_patch(bottom, right, top, left): # create an initial mesh (correct corners) which we will morph into the right one p1 = bottom.order(0) p2 = left.order(0) - p = max(p1,p2) + p = max(p1, p2) linear = BSplineBasis(2) srf = Surface(linear, linear, [bottom[0], bottom[-1], top[0], top[-1]]) - srf.raise_order(p1-2, p2-2) + srf.raise_order(p1 - 2, p2 - 2) for k in bottom.knots(0, True)[p1:-p1]: srf.insert_knot(k, 0) for k in left.knots(0, True)[p2:-p2]: @@ -471,27 +564,28 @@ def finitestrain_patch(bottom, right, top, left): # create computational mesh n1 = len(bottom) n2 = len(left) - dim= left.dimension + dim = left.dimension domain, geom = mesh.rectilinear(srf.knots()) ns = function.Namespace() - ns.basis = domain.basis('spline', degree(srf), knotmultiplicities=multiplicities(srf)).vector( 2 ) - ns.phi = domain.basis('spline', degree(srf), knotmultiplicities=multiplicities(srf)) - ns.eye = np.array([[1,0],[0,1]]) - ns.cp = controlpoints(srf) - ns.x_i = 'cp_ni phi_n' - ns.lmbda = 1 - ns.mu = 1 + ns.basis = domain.basis("spline", degree(srf), knotmultiplicities=multiplicities(srf)).vector(2) + ns.phi = domain.basis("spline", degree(srf), knotmultiplicities=multiplicities(srf)) + ns.eye = np.array([[1, 0], [0, 1]]) + ns.cp = controlpoints(srf) + ns.x_i = "cp_ni phi_n" + ns.lmbda = 1 + ns.mu = 1 # add total boundary conditions # for hard problems these will be taken in steps and multiplied by dt every # time (quasi-static iterations) - constraints = np.array([[[np.nan]*n2]*n1]*dim) + constraints = np.array([[[np.nan] * n2] * n1] * dim) for d in range(dim): - constraints[d, 0, :] = (left[ :,d] - srf[ 0, :,d]) - constraints[d,-1, :] = (right[ :,d] - srf[-1, :,d]) - constraints[d, :, 0] = (bottom[:,d] - srf[ :, 0,d]) - constraints[d, :,-1] = (top[ :,d] - srf[ :,-1,d]) - # TODO: Take a close look at the logic below + constraints[d, 0, :] = left[:, d] - srf[0, :, d] + constraints[d, -1, :] = right[:, d] - srf[-1, :, d] + constraints[d, :, 0] = bottom[:, d] - srf[:, 0, d] + constraints[d, :, -1] = top[:, d] - srf[:, -1, d] + + # TODO(Kjetil): Take a close look at the logic below # in order to iterate, we let t0=0 be current configuration and t1=1 our target configuration # if solver divergeces (too large deformation), we will try with dt=0.5. If this still @@ -501,39 +595,41 @@ def finitestrain_patch(bottom, right, top, left): # t0 = 0 # t1 = 1 # while t0 < 1: - # dt = t1-t0 - n = 10 - dt = 1/n + # dt = t1-t0 + n = 10 + dt = 1 / n for i in range(n): # print(' ==== Quasi-static '+str(t0*100)+'-'+str(t1*100)+' % ====') - print(' ==== Quasi-static '+str(i/(n-1)*100)+' % ====') + print(" ==== Quasi-static " + str(i / (n - 1) * 100) + " % ====") # define the non-linear finite strain problem formulation - ns.cp = np.reshape(srf[:,:,:].swapaxes(0,1), (n1*n2, dim), order='F') - ns.x_i = 'cp_ni phi_n' # geometric mapping (reference geometry) - ns.u_i = 'basis_ki ?w_k' # displacement (unknown coefficients w_k) - ns.X_i = 'x_i + u_i' # displaced geometry - ns.strain_ij = '.5 (u_i,j + u_j,i + u_k,i u_k,j)' - ns.stress_ij = 'lmbda strain_kk eye_ij + 2 mu strain_ij' + ns.cp = np.reshape(srf[:, :, :].swapaxes(0, 1), (n1 * n2, dim), order="F") + ns.x_i = "cp_ni phi_n" # geometric mapping (reference geometry) + ns.u_i = "basis_ki ?w_k" # displacement (unknown coefficients w_k) + ns.X_i = "x_i + u_i" # displaced geometry + ns.strain_ij = ".5 (u_i,j + u_j,i + u_k,i u_k,j)" + ns.stress_ij = "lmbda strain_kk eye_ij + 2 mu strain_ij" # try: - residual = domain.integral(ns.eval_n('stress_ij basis_ni,j d:X'), degree=2*p) - cons = np.ndarray.flatten(constraints*dt, order='C') - lhs = solver.newton('w', residual, constrain=cons).solve( - tol=state.controlpoint_absolute_tolerance, maxiter=8) + residual = domain.integral(ns.eval_n("stress_ij basis_ni,j d:X"), degree=2 * p) + cons = np.ndarray.flatten(constraints * dt, order="C") + lhs = solver.newton("w", residual, constrain=cons).solve( + tol=state.controlpoint_absolute_tolerance, maxiter=8 + ) # store the results on a splipy object and continue - geom = lhs.reshape((n2,n1,dim), order='F') - srf[:,:,:] += geom.swapaxes(0,1) + cps = lhs.reshape((n2, n1, dim), order="F") + srf[:, :, :] += cps.swapaxes(0, 1) # t0 += dt # t1 = 1 # except solver.SolverError: # newton method fail to converge, try a smaller step length 'dt' - # t1 = (t1+t0)/2 + # t1 = (t1+t0)/2 return srf -def thicken(curve, amount): - """ Generate a surface by adding thickness to a curve. + +def thicken(curve: Curve, amount: Union[Scalar, Callable]) -> Surface: + """Generate a surface by adding thickness to a curve. - For 2D curves this will generate a 2D planar surface with the curve through the center. @@ -566,39 +662,40 @@ def thicken(curve, amount): curve = curve.clone() # clone input curve, throw away input reference t = curve.bases[0].greville() + if curve.dimension == 2: # linear parametrization across domain n = len(curve) left_points = np.zeros((n, 2)) right_points = np.zeros((n, 2)) - linear = BSplineBasis(2) + # linear = BSplineBasis(2) - x = curve.evaluate(t) # curve at interpolation points - v = curve.derivative(t) # velocity at interpolation points - l = np.sqrt(v[:, 0]**2 + v[:, 1]**2) # normalizing factor for velocity + x = curve.evaluate(t) # curve at interpolation points + v = curve.derivative(t) # velocity at interpolation points + l = np.sqrt(v[:, 0] ** 2 + v[:, 1] ** 2) # normalizing factor for velocity for i in range(n): - if l[i] < 1e-13: # in case of zero velocity, use neighbour instead - if i>0: - v[i,:] = v[i-1,:] + if l[i] < 1e-13: # in case of zero velocity, use neighbour instead + if i > 0: + v[i, :] = v[i - 1, :] else: - v[i,:] = v[i+1,:] + v[i, :] = v[i + 1, :] else: - v[i,:] /= l[i] + v[i, :] /= l[i] - if inspect.isfunction(amount): + if callable(amount): arg_names = inspect.signature(amount).parameters argc = len(arg_names) - argv = [0] * argc + argv: list[Any] = [0] * argc for i in range(n): # build up the list of arguments (in case not all of (x,y,t) are specified) - for j,name in enumerate(arg_names): - if name == 'x': + for j, name in enumerate(arg_names): + if name == "x": argv[j] = x[i, 0] - elif name == 'y': + elif name == "y": argv[j] = x[i, 1] - elif name == 'z': + elif name == "z": argv[j] = 0.0 - elif name == 't': + elif name == "t": argv[j] = t[i] # figure out the distane at this particular point dist = amount(*argv) @@ -606,23 +703,24 @@ def thicken(curve, amount): # store interpolation points right_points[i, 0] = x[i, 0] - v[i, 1] * dist # x at bottom right_points[i, 1] = x[i, 1] + v[i, 0] * dist # y at bottom - left_points[ i, 0] = x[i, 0] + v[i, 1] * dist # x at top - left_points[ i, 1] = x[i, 1] - v[i, 0] * dist # y at top + left_points[i, 0] = x[i, 0] + v[i, 1] * dist # x at top + left_points[i, 1] = x[i, 1] - v[i, 0] * dist # y at top else: right_points[:, 0] = x[:, 0] - v[:, 1] * amount # x at bottom right_points[:, 1] = x[:, 1] + v[:, 0] * amount # y at bottom - left_points[ :, 0] = x[:, 0] + v[:, 1] * amount # x at top - left_points[ :, 1] = x[:, 1] - v[:, 0] * amount # y at top + left_points[:, 0] = x[:, 0] + v[:, 1] * amount # x at top + left_points[:, 1] = x[:, 1] - v[:, 0] * amount # y at top # perform interpolation on each side right = curve_factory.interpolate(right_points, curve.bases[0]) - left = curve_factory.interpolate(left_points, curve.bases[0]) + left = curve_factory.interpolate(left_points, curve.bases[0]) return edge_curves(right, left) - else: # dimension=3, we will create a surrounding tube - return sweep(curve, curve_factory.circle(r=amount)) + assert not callable(amount) + return sweep(curve, curve_factory.circle(r=amount)) + -def sweep(path, shape): - """ Generate a surface by sweeping a shape along a path +def sweep(path: Curve, shape: Curve) -> Surface: + """Generate a surface by sweeping a shape along a path. The resulting surface is an approximation generated by interpolating at the Greville points. It is generated by sweeping a shape curve along a path. @@ -641,7 +739,7 @@ def sweep(path, shape): n1 = b1.num_functions() n2 = b2.num_functions() # this requires binormals and normals, which only work in 3D, so assume this here - X = np.zeros((n1,n2, 3)) + X = np.zeros((n1, n2, 3)) for i in range(n1): u = b1.greville(i) x = path(u) @@ -650,13 +748,13 @@ def sweep(path, shape): for j in range(n2): v = b2.greville(j) y = shape(v) - X[i,j,:] = x + N*y[0] + B*y[1] + X[i, j, :] = x + N * y[0] + B * y[1] - return interpolate(X, [b1,b2]) + return interpolate(X, [b1, b2]) -def loft(*curves): - """ Generate a surface by lofting a series of curves +def loft(*curves: Curve) -> Surface: + """Generate a surface by lofting a series of curves. The resulting surface is interpolated at all input curves and a smooth transition between these curves is computed as a cubic spline interpolation in the lofting @@ -689,63 +787,63 @@ def loft(*curves): """ if len(curves) == 1: - curves = curves[0] + curves = cast(tuple[Curve, ...], curves[0]) # clone input, so we don't change those references # make sure everything has the same dimension since we need to compute length - curves = [c.clone().set_dimension(3) for c in curves] - if len(curves)==2: + curves = tuple(c.clone().set_dimension(3) for c in curves) + if len(curves) == 2: return edge_curves(curves) - elif len(curves)==3: + + if len(curves) == 3: # can't do cubic spline interpolation, so we'll do quadratic basis2 = BSplineBasis(3) - dist = basis2.greville() + dist = basis2.greville() else: - x = [c.center() for c in curves] + x = np.array([c.center() for c in curves], dtype=float) # create knot vector from the euclidian length between the curves - dist = [0] - for (x1,x0) in zip(x[1:],x[:-1]): - dist.append(dist[-1] + np.linalg.norm(x1-x0)) + dist = np.zeros((len(x),), dtype=float) + curve_length_parametrization(x, buffer=dist[1:]) # using "free" boundary condition by setting N'''(u) continuous at second to last and second knot - knot = [dist[0]]*4 + dist[2:-2] + [dist[-1]]*4 + knot = np.fromiter(chain(repeat(dist[0], 4), dist[2:-2], repeat(dist[-1], 4)), dtype=float) basis2 = BSplineBasis(4, knot) n = len(curves) for i in range(n): - for j in range(i+1,n): + for j in range(i + 1, n): Curve.make_splines_identical(curves[i], curves[j]) basis1 = curves[0].bases[0] - m = basis1.num_functions() - u = basis1.greville() # parametric interpolation points - v = dist # parametric interpolation points + m = basis1.num_functions() + u = basis1.greville() # parametric interpolation points + v = dist # parametric interpolation points # compute matrices - Nu = basis1(u) - Nv = basis2(v) + Nu = basis1(u) + Nv = basis2(v) Nu_inv = np.linalg.inv(Nu) Nv_inv = np.linalg.inv(Nv) # compute interpolation points in physical space - x = np.zeros((m,n, curves[0][0].size)) + x = np.zeros((m, n, curves[0][0].size)) for i in range(n): - x[:,i,:] = Nu @ curves[i].controlpoints + x[:, i, :] = Nu @ curves[i].controlpoints # solve interpolation problem - cp = np.tensordot(Nv_inv, x, axes=(1,1)) - cp = np.tensordot(Nu_inv, cp, axes=(1,1)) + cp = np.tensordot(Nv_inv, x, axes=(1, 1)) + cp = np.tensordot(Nu_inv, cp, axes=(1, 1)) # re-order controlpoints so they match up with Surface constructor cp = cp.transpose((1, 0, 2)) - cp = cp.reshape(n*m, cp.shape[2]) + cp = cp.reshape(n * m, cp.shape[2]) return Surface(basis1, basis2, cp, curves[0].rational) -def interpolate(x, bases, u=None): - """ Interpolate a surface on a set of regular gridded interpolation points `x`. +def interpolate(x: FArray, bases: Sequence[BSplineBasis], u: Optional[Sequence[Scalars]] = None) -> Surface: + """Interpolate a surface on a set of regular gridded interpolation points `x`. The points can be either a matrix (in which case the first index is interpreted as a flat row-first index of the interpolation grid) or a 3D @@ -764,17 +862,17 @@ def interpolate(x, bases, u=None): x = x.reshape(surf_shape + [dim]) if u is None: u = [b.greville() for b in bases] - N_all = [b(t) for b,t in zip(bases, u)] + N_all = [b(t) for b, t in zip(bases, u)] N_all.reverse() cp = x for N in N_all: - cp = np.tensordot(np.linalg.inv(N), cp, axes=(1,1)) + cp = np.tensordot(np.linalg.inv(N), cp, axes=(1, 1)) - return Surface(bases[0], bases[1], cp.transpose(1,0,2).reshape((np.prod(surf_shape),dim))) + return Surface(bases[0], bases[1], cp.transpose(1, 0, 2).reshape((np.prod(surf_shape), dim))) -def least_square_fit(x, bases, u): - """ Perform a least-square fit of a point cloud `x` onto a spline basis. +def least_square_fit(x: FArray, bases: Sequence[BSplineBasis], u: Sequence[Scalars]) -> Surface: + """Perform a least-square fit of a point cloud `x` onto a spline basis. The points can be either a matrix (in which case the first index is interpreted as a flat row-first index of the interpolation grid) or a 3D @@ -792,19 +890,19 @@ def least_square_fit(x, bases, u): dim = x.shape[-1] if len(x.shape) == 2: x = x.reshape(surf_shape + [dim]) - N_all = [b(t) for b,t in zip(bases, u)] + N_all = [b(t) for b, t in zip(bases, u)] N_all.reverse() cp = x for N in N_all: - cp = np.tensordot(N.T, cp, axes=(1,1)) + cp = np.tensordot(N.T, cp, axes=(1, 1)) for N in N_all: - cp = np.tensordot(np.linalg.inv(N.T @ N), cp, axes=(1,1)) + cp = np.tensordot(np.linalg.inv(N.T @ N), cp, axes=(1, 1)) - return Surface(bases[0], bases[1], cp.transpose(1,0,2).reshape((np.prod(surf_shape),dim))) + return Surface(bases[0], bases[1], cp.transpose(1, 0, 2).reshape((np.prod(surf_shape), dim))) -def teapot(): - """ Generate the Utah teapot as 32 cubic bezier patches. This teapot has a +def teapot() -> list[Surface]: + """Generate the Utah teapot as 32 cubic bezier patches. This teapot has a rim, but no bottom. It is also self-intersecting making it unsuitable for perfect-match multipatch modeling. @@ -813,17 +911,17 @@ def teapot(): :return: The utah teapot :rtype: List of Surface """ - path = join(dirname(realpath(__file__)), 'templates', 'teapot.bpt') - with open(path) as f: + path = Path(__file__).parent / "templates" / "teapot.bpt" + with path.open("r") as f: results = [] numb_patches = int(f.readline()) for i in range(numb_patches): - p = np.fromstring(f.readline(), dtype=np.uint8, count=2, sep=' ') - basis1 = BSplineBasis(p[0]+1) - basis2 = BSplineBasis(p[1]+1) + p = np.fromstring(f.readline(), dtype=np.uint8, count=2, sep=" ") + basis1 = BSplineBasis(p[0] + 1) + basis2 = BSplineBasis(p[1] + 1) ncp = basis1.num_functions() * basis2.num_functions() - cp = [np.fromstring(f.readline(), dtype=float, count=3, sep=' ') for j in range(ncp)] + cp = [np.fromstring(f.readline(), dtype=float, count=3, sep=" ") for j in range(ncp)] results.append(Surface(basis1, basis2, cp)) return results diff --git a/splipy/trimmedsurface.py b/splipy/trimmedsurface.py index f5d4a85..42784ea 100644 --- a/splipy/trimmedsurface.py +++ b/splipy/trimmedsurface.py @@ -1,18 +1,21 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations from math import pi +from typing import TYPE_CHECKING, Any, Literal, Optional, Sequence import numpy as np from scipy.spatial import ConvexHull -from .basis import BSplineBasis -from .curve import Curve -from .surface import Surface -from .splineobject import SplineObject -from .utils import ensure_listlike, check_direction, sections from . import state +from .surface import Surface + +if TYPE_CHECKING: + from .basis import BSplineBasis + from .curve import Curve + from .types import FArray, Scalar -__all__ = ['TrimmedSurface'] + +__all__ = ["TrimmedSurface"] class TrimmedSurface(Surface): @@ -21,10 +24,20 @@ class TrimmedSurface(Surface): Represents a surface: an object with a two-dimensional parameter space and one or more interior closed trimming loops.""" - _intended_pardim = 2 - - def __init__(self, basis1=None, basis2=None, controlpoints=None, rational=False, loops=None, **kwargs): - """ Construct a surface with the given basis and control points. + boundaries: list[list[Curve]] + convexhull: list[FArray] + rotation: list[Literal["clockwise", "counterclockwise"]] + + def __init__( + self, + basis1: Optional[BSplineBasis] = None, + basis2: Optional[BSplineBasis] = None, + controlpoints: Any = None, + rational: bool = False, + loops: Optional[Sequence[Sequence[Curve]]] = None, + raw: bool = False, + ) -> None: + """Construct a surface with the given basis and control points. The default is to create a linear one-element mapping from and to the unit square. @@ -39,35 +52,34 @@ def __init__(self, basis1=None, basis2=None, controlpoints=None, rational=False, :raises RuntimeError: If the loops are not contained to dimension 2 (parametric space), or if they are not closed, or if they are not looping properly """ - super(Surface, self).__init__([basis1, basis2], controlpoints, rational, **kwargs) + super().__init__(basis1, basis2, controlpoints, rational=rational, raw=raw) + # make sure to make deep copies of the loops so nothing bad happens - self.boundaries = [[l.clone() for l in one_loop] for one_loop in loops] + if loops: + self.boundaries = [[l.clone() for l in one_loop] for one_loop in loops] + else: + self.boundaries = [] # error check input curves for one_loop in self.boundaries: for curve in one_loop: if not curve.dimension == 2: - raise RuntimeError('Boundary curves need to have dimension 2') + raise RuntimeError("Boundary curves need to have dimension 2") for i in range(len(one_loop)): # print(state.parametric_absolute_tolerance) # print(one_loop[i-1][-1,:], ' ', one_loop[i][0,:]) - if not np.allclose(one_loop[i-1][-1,:], one_loop[i][0,:], - rtol=state.parametric_relative_tolerance, - atol=state.parametric_absolute_tolerance): - raise RuntimeError('Boundary curves not closed') + if not np.allclose( + one_loop[i - 1][-1, :], + one_loop[i][0, :], + rtol=state.parametric_relative_tolerance, + atol=state.parametric_absolute_tolerance, + ): + raise RuntimeError("Boundary curves not closed") self.__compute_convex_hulls() - def edges(self): - """Return the four edge curves in (parametric) order: umin, umax, vmin, vmax - - :return: Edge curves - :rtype: (Curve) - """ - return tuple(self.section(*args) for args in sections(2, 1)) - - def __compute_convex_hulls(self): - self.rotation = [] + def __compute_convex_hulls(self) -> None: + self.rotation = [] self.convexhull = [] for loop in self.boundaries: # don't know if we really need the error test for boundary loops, but @@ -75,53 +87,51 @@ def __compute_convex_hulls(self): # get all controlpoints for all the curves, make sure not to double- # count the end and start of subsequent curve-pieces - x = np.vstack([curve[1:,:2] for curve in loop]) + x = np.vstack([curve[1:, :2] for curve in loop]) # compute vectors between the control points (velocity approximation?) - dx = np.diff(np.append(x, [x[0,:]], axis=0), axis=0) + dx = np.diff(np.append(x, [x[0, :]], axis=0), axis=0) # compute angle at all points - theta = np.arctan2(dx[:,1], dx[:,0]) + theta = np.arctan2(dx[:, 1], dx[:, 0]) # compute angle difference at all (control-)points dt = np.diff(np.append(theta, theta[0])) - dt[np.where( dt<-pi) ] += 2*pi - dt[np.where( dt>+pi) ] -= 2*pi + dt[np.where(dt < -pi)] += 2 * pi + dt[np.where(dt > +pi)] -= 2 * pi total_rotation = np.sum(dt) - if np.allclose(total_rotation, 2*pi): - self.rotation.append('counterclockwise') - elif np.allclose(total_rotation, -2*pi): - self.rotation.append('clockwise') + if np.allclose(total_rotation, 2 * pi): + self.rotation.append("counterclockwise") + elif np.allclose(total_rotation, -2 * pi): + self.rotation.append("clockwise") else: - raise RuntimeError('Boundary loops does not loop exactly once') + raise RuntimeError("Boundary loops does not loop exactly once") hull = ConvexHull(x) - self.convexhull.append(x[hull.vertices,:]) - # print(self.convexhull[-1]) + self.convexhull.append(x[hull.vertices, :]) - def is_contained(self, u, v): - """ Returns a boolean mask if the input points are inside (True) or + def is_contained(self, u: Scalar, v: Scalar) -> bool: + """Returns a boolean mask if the input points are inside (True) or outside (False) of the trimming curves.""" - raise NotImplementedError('This has yet to be implemented') + raise NotImplementedError("This has yet to be implemented") # do a quick test based on convex hull - self.__is_contained_coarse(u,v) + self.__is_contained_coarse(u, v) # for all points that are still undecided, do a fine-grained newton # iteration approach - self.__is_contained_fine(u,v) + self.__is_contained_fine(u, v) return False - def __is_contained_fine(self, u, v): - """ Does a fine test based on parametric curve representation to see if + def __is_contained_fine(self, u: Scalar, v: Scalar) -> bool: + """Does a fine test based on parametric curve representation to see if points are inside or outside trimming domain. Trimming curves are high- polynomial representations, so figuring this out means newton iteration to locate nearest point on curve and decide if this is inside or outside domain.""" return False - def __is_contained_coarse(self, u, v): - """ Does a course test based on control-grid to see if points are inside or + def __is_contained_coarse(self, u: Scalar, v: Scalar) -> bool: + """Does a course test based on control-grid to see if points are inside or outside domain. Inside control-grid means inside a trimming loop and outputs False. Outside the *convex hull* of a control-grid means""" return False - diff --git a/splipy/types.py b/splipy/types.py new file mode 100644 index 0000000..901f295 --- /dev/null +++ b/splipy/types.py @@ -0,0 +1,32 @@ +from collections.abc import Sequence +from typing import Literal, TypedDict, Union + +from numpy import float64, int_ +from numpy.typing import NDArray + +Direction = Union[int, Literal["u", "U", "v", "V", "w", "W"]] + +FArray = NDArray[float64] +IArray = NDArray[int_] + +Scalar = Union[ + float, + float64, +] + +Scalars = Union[ + Sequence[Scalar], + FArray, +] + +ScalarOrScalars = Union[Scalar, Scalars] + +SectionElt = Literal[-1, 0, None] +SectionLike = Sequence[SectionElt] +Section = tuple[SectionElt, ...] + + +class SectionKwargs(TypedDict, total=False): + u: SectionElt + v: SectionElt + w: SectionElt diff --git a/splipy/utils/NACA.py b/splipy/utils/NACA.py index 5c9daea..74b2ea3 100644 --- a/splipy/utils/NACA.py +++ b/splipy/utils/NACA.py @@ -1,16 +1,16 @@ -# -*- coding: utf-8 -*- +from math import sqrt import numpy as np -from ..curve import Curve -from ..basis import BSplineBasis -from .. import surface_factory +from splipy import surface_factory +from splipy.basis import BSplineBasis +from splipy.curve import Curve -__all__ = ['camber', 'NACA'] +__all__ = ["camber", "NACA"] -def camber(M, P, order=5): - """ Create the NACA centerline used for wing profiles. This is given as +def camber(M: float, P: float, order: int = 5) -> Curve: + """Create the NACA centerline used for wing profiles. This is given as an exact quadratic piecewise polynomial y(x), see http://airfoiltools.com/airfoil/naca4digit. The method will produce one of two representations: For order<5 it will create x(t)=t and @@ -24,10 +24,10 @@ def camber(M, P, order=5): :rtype : Curve """ # parametrized by x=t or x="t^2" if order>4 - M = M / 100.0 - P = P / 10.0 + M /= 100 + P /= 10 + basis = BSplineBasis(order) - # basis.insert_knot([P]*(order-2)) # insert a C1-knot for i in range(order - 2): basis.insert_knot(P) @@ -37,13 +37,13 @@ def camber(M, P, order=5): for i in range(n): if t[i] <= P: if order > 4: - x[i, 0] = t[i]**2 / P + x[i, 0] = t[i] ** 2 / P else: x[i, 0] = t[i] x[i, 1] = M / P / P * (2 * P * x[i, 0] - x[i, 0] * x[i, 0]) else: if order > 4: - x[i, 0] = (t[i]**2 - 2 * t[i] + P) / (P - 1) + x[i, 0] = (t[i] ** 2 - 2 * t[i] + P) / (P - 1) else: x[i, 0] = t[i] x[i, 1] = M / (1 - P) / (1 - P) * (1 - 2 * P + 2 * P * x[i, 0] - x[i, 0] * x[i, 0]) @@ -52,8 +52,8 @@ def camber(M, P, order=5): return Curve(basis, controlpoints) -def NACA(M, P, X, n=40, order=5, closed=False): - """ Create the NACA 4 digit airfoil. This is generated as an approximation +def NACA(M: float, P: float, X: float, n: int = 40, order: int = 5, closed: bool = False) -> Curve: + """Create the NACA 4 digit airfoil. This is generated as an approximation through the use of SurfaceFactory.thicken functions. :param M: Max camber height (y) given as percentage 0% to 9% of length :type M: Int 0 float: a0 = 0.2969 a1 = -0.126 a2 = -0.3516 a3 = 0.2843 a4 = -0.1036 if closed else -0.1015 - return T / 0.2 * (a0 * np.sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4) + return T / 0.2 * (a0 * sqrt(x) + a1 * x + a2 * x**2 + a3 * x**3 + a4 * x**4) surf = surface_factory.thicken(center_line, thickness) _, _, top, btm = surf.edges() diff --git a/splipy/utils/__init__.py b/splipy/utils/__init__.py index 17ae02a..a3fbc62 100644 --- a/splipy/utils/__init__.py +++ b/splipy/utils/__init__.py @@ -1,21 +1,47 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations +from collections.abc import Sized from itertools import combinations, product from math import atan2, sqrt -import numpy as np - -try: - from collections.abc import Sized -except ImportError: - from collections import Sized +from typing import ( + TYPE_CHECKING, + Any, + Hashable, + Iterable, + Iterator, + Literal, + Optional, + Sequence, + SupportsFloat, + TypeVar, + Union, +) -def is_right_hand(patch, tol=1e-3): - param = tuple((a+b)/2 for a,b in zip(patch.start(), patch.end())) +import numpy as np +from typing_extensions import Unpack + +if TYPE_CHECKING: + from splipy.splineobject import SplineObject + from splipy.types import ( + Direction, + FArray, + Scalar, + ScalarOrScalars, + Scalars, + Section, + SectionElt, + SectionKwargs, + SectionLike, + ) + + +def is_right_hand(patch: SplineObject, tol: float = 1e-3) -> bool: + param = tuple((a + b) / 2 for a, b in zip(patch.start(), patch.end())) if patch.dimension == patch.pardim == 3: - du = patch.derivative(*param, d=(1,0,0)) - dv = patch.derivative(*param, d=(0,1,0)) - dw = patch.derivative(*param, d=(0,0,1)) + du = patch.derivative(*param, d=(1, 0, 0)) + dv = patch.derivative(*param, d=(0, 1, 0)) + dw = patch.derivative(*param, d=(0, 0, 1)) # Normalize du = du / np.linalg.norm(du) @@ -23,29 +49,36 @@ def is_right_hand(patch, tol=1e-3): dw = dw / np.linalg.norm(dw) # Compare cross product - return np.dot(dw, np.cross(du, dv)) >= tol + return bool(np.dot(dw, np.cross(du, dv)) >= tol) if patch.dimension == patch.pardim == 2: - du = patch.derivative(*param, d=(1,0)) - dv = patch.derivative(*param, d=(0,1)) + du = patch.derivative(*param, d=(1, 0)) + dv = patch.derivative(*param, d=(0, 1)) # Normalize du = du / np.linalg.norm(du) dv = dv / np.linalg.norm(dv) - return np.cross(du, dv) >= tol + return bool(np.cross(du, dv) >= tol) raise ValueError("Right-handedness only defined for 2D or 3D patches in 2D or 3D space, respectively") -def rotation_matrix(theta, axis): - axis = axis / np.sqrt(np.dot(axis, axis)) + +def rotation_matrix(theta: Scalar, axis: Scalars) -> FArray: + axis = np.asarray(axis, dtype=float) + axis /= np.sqrt(np.dot(axis, axis)) a = np.cos(theta / 2) - b, c, d = -axis*np.sin(theta / 2) - return np.array([[a*a+b*b-c*c-d*d, 2*(b*c-a*d), 2*(b*d+a*c)], - [2*(b*c+a*d), a*a+c*c-b*b-d*d, 2*(c*d-a*b)], - [2*(b*d-a*c), 2*(c*d+a*b), a*a+d*d-b*b-c*c]]) + b, c, d = -axis * np.sin(theta / 2) + return np.array( + [ + [a * a + b * b - c * c - d * d, 2 * (b * c - a * d), 2 * (b * d + a * c)], + [2 * (b * c + a * d), a * a + c * c - b * b - d * d, 2 * (c * d - a * b)], + [2 * (b * d - a * c), 2 * (c * d + a * b), a * a + d * d - b * b - c * c], + ] + ) -def sections(src_dim, tgt_dim): + +def sections(src_dim: int, tgt_dim: int) -> Iterator[Section]: """Generate all boundary sections from a source dimension to a target dimension. For example, `sections(3,1)` generates all edges on a volume. @@ -57,13 +90,15 @@ def sections(src_dim, tgt_dim): nfixed = src_dim - tgt_dim for fixed in combinations(range(src_dim), r=nfixed): # Enumerate all {0,-1}^n over the fixed directions - for indices in product([0, -1], repeat=nfixed): - args = [None] * src_dim + values: list[Literal[-1, 0]] = [0, -1] + for indices in product(values, repeat=nfixed): + args: list[Literal[-1, 0, None]] = [None] * src_dim for f, i in zip(fixed, indices[::-1]): args[f] = i - yield args + yield tuple(args) + -def section_from_index(src_dim, tgt_dim, i): +def section_from_index(src_dim: int, tgt_dim: int, i: int) -> Section: """Return the i'th section from a source dimension to a target dimension. See :func:`splipy.Utils.sections` for more information. @@ -71,16 +106,20 @@ def section_from_index(src_dim, tgt_dim, i): for j, s in enumerate(sections(src_dim, tgt_dim)): if i == j: return s + assert False + -def section_to_index(section): +def section_to_index(section: SectionLike) -> int: """Return the index corresponding to a section.""" src_dim = len(section) tgt_dim = sum(1 for s in section if s is None) for i, t in enumerate(sections(src_dim, tgt_dim)): if tuple(section) == tuple(t): return i + assert False -def check_section(*args, **kwargs): + +def check_section(*args: SectionElt, pardim: int = 0, **kwargs: Unpack[SectionKwargs]) -> Section: """check_section(u, v, ...) Parse arguments and return a section spec. @@ -88,75 +127,76 @@ def check_section(*args, **kwargs): The keyword argument `pardim` *must* be provided. The return value is a section as described in :func:`splipy.Utils.sections`. """ - pardim = kwargs['pardim'] - args = list(args) - while len(args) < pardim: - args.append(None) - for k in set(kwargs.keys()) & set('uvw'): - index = 'uvw'.index(k) - args[index] = kwargs[k] - return args - -def check_direction(direction, pardim): - if direction in {0, 'u', 'U'} and 0 < pardim: + args_list = list(args) + while len(args_list) < pardim: + args_list.append(None) + if "u" in kwargs: + args_list[0] = kwargs["u"] + if "v" in kwargs: + args_list[1] = kwargs["v"] + if "w" in kwargs: + args_list[2] = kwargs["w"] + return tuple(args_list) + + +def check_direction(direction: Direction, pardim: int) -> int: + if direction in {0, "u", "U"} and pardim > 0: return 0 - elif direction in {1, 'v', 'V'} and 1 < pardim: + if direction in {1, "v", "V"} and pardim > 1: return 1 - elif direction in {2, 'w', 'W'} and 2 < pardim: + if direction in {2, "w", "W"} and pardim > 2: return 2 - raise ValueError('Invalid direction') + raise ValueError("Invalid direction") -def ensure_flatlist(x): - """Flattens a multi-list x to a single index list.""" - if isinstance(x[0], Sized): - return x[0] - return x -def is_singleton(x): +def is_singleton(x: Any) -> bool: """Checks if x is list-like.""" return not isinstance(x, Sized) -def ensure_listlike(x, dups=1): + +def ensure_scalars(x: Union[ScalarOrScalars, tuple[Scalar]], dups: int = 1) -> list[float]: + if isinstance(x, SupportsFloat) and not isinstance(x, np.ndarray): + return [float(x)] * dups + retval = list(map(float, x)) + if len(retval) < dups and retval: + retval.extend(float(x[-1]) for _ in range(dups - len(retval))) + return retval + + +T = TypeVar("T", covariant=True) + + +def ensure_listlike(x: Union[T, Sequence[T]], dups: int = 1) -> list[T]: """Wraps x in a list if it's not list-like.""" - try: - while len(x) < dups: - x = list(x) - x.append(x[-1]) - return x - except TypeError: - return [x] * dups - except IndexError: - return [] - -def rotate_local_x_axis(xaxis=(1,0,0), normal=(0,0,1)): + if isinstance(x, Sequence): + y = list(x) + while len(y) < dups: + y.append(y[-1]) + return y + return [x] * dups + + +def rotate_local_x_axis(xaxis: Scalars = (1, 0, 0), normal: Scalars = (0, 0, 1)) -> float: # rotate xaxis vector back to reference domain (r=1, around origin) theta = atan2(normal[1], normal[0]) - phi = atan2(sqrt(normal[0]**2+normal[1]**2), normal[2]) - R1 = rotation_matrix(-theta, (0,0,1)) - R2 = rotation_matrix(-phi, (0,1,0)) - if len(xaxis) != 3: # typically 2D geometries + phi = atan2(sqrt(normal[0] ** 2 + normal[1] ** 2), normal[2]) + R1 = rotation_matrix(-theta, (0, 0, 1)) + R2 = rotation_matrix(-phi, (0, 1, 0)) + if len(xaxis) != 3: # typically 2D geometries xaxis = [xaxis[0], xaxis[1], 0] - xaxis = np.array([xaxis]) - xaxis = xaxis.dot(R1).dot(R2) + xaxis_array = np.array([xaxis], dtype=float).dot(R1).dot(R2) + # xaxis = xaxis.dot(R1).dot(R2) # if xaxis is orthogonal to normal, then xaxis[2]==0 now. If not then # treating it as such is the closest projection, which makes perfect sense - return atan2(xaxis[0,1], xaxis[0,0]) - -def flip_and_move_plane_geometry(obj, center=(0,0,0), normal=(0,0,1)): - """re-orients a planar geometry by moving it to a different location and - tilting it""" - # don't touch it if not needed. translate or scale operations may force - # object into 3D space - if not np.allclose(normal, np.array([0,0,1])): - theta = atan2(normal[1], normal[0]) - phi = atan2(sqrt(normal[0]**2+normal[1]**2), normal[2]) - obj.rotate(phi, (0,1,0)) - obj.rotate(theta, (0,0,1)) - if not np.allclose(center, 0): - obj.translate(center) - return obj - -def reshape(cps, newshape, order='C', ncomps=None): + return float(np.arctan2(xaxis_array[0, 1], xaxis_array[0, 0])) + + +def reshape( + cps: FArray, + newshape: tuple[int, ...], + order: Literal["F", "C"] = "C", + ncomps: Optional[int] = None, +) -> FArray: """Like numpy's reshape, but preserves control points of several dimensions that are stored contiguously. @@ -170,100 +210,133 @@ def reshape(cps, newshape, order='C', ncomps=None): """ npts = np.prod(newshape) if ncomps is None: - try: - ncomps = cps.size // npts - except AttributeError: - ncomps = len(cps) // npts + ncomps = int(cps.size // npts) - if order == 'C': + if order == "C": shape = list(newshape) + [ncomps] - elif order == 'F': + elif order == "F": shape = list(newshape[::-1]) + [ncomps] cps = np.reshape(cps, shape) - if order == 'F': + if order == "F": spec = list(range(len(newshape)))[::-1] + [len(newshape)] cps = cps.transpose(spec) return cps -def uniquify(iterator): + +H = TypeVar("H", bound=Hashable) + + +def uniquify(iterator: Iterable[H]) -> Iterator[H]: """Iterates over all elements in `iterator`, removing duplicates.""" - seen = set() + seen: set[H] = set() for i in iterator: if i in seen: continue seen.add(i) yield i -def raise_order_1D(n, k, T, P, m, periodic): - """ Implementation of method in "Efficient Degree Elevation and Knot Insertion - for B-spline Curves using Derivatives" by Qi-Xing Huang a Shi-Min Hu, Ralph R Martin. Only the case of open knot vector is fully implemented + +def raise_order_1D( + n: int, + k: int, + T: FArray, + P: FArray, + m: int, + periodic: int, +) -> FArray: + """Implementation of method in "Efficient Degree Elevation and Knot Insertion + for B-spline Curves using Derivatives" by Qi-Xing Huang a Shi-Min Hu, Ralph R Martin. + Only the case of open knot vector is fully implemented. + :param int n: (n+1) is the number of initial basis functions :param int k: spline order :param T: knot vector :param P: weighted NURBS coefficients :param int m: number of degree elevations - :param int periodic: Number of continuous derivatives at start and end. -1 is not periodic, 0 is continuous, etc. + :param int periodic: Number of continuous derivatives at start and end. + -1 is not periodic, 0 is continuous, etc. :return Q: new control points """ - u = np.unique(T[k-1:-k+1]) + from splipy.basis import BSplineBasis + + u = np.unique(T[k - 1 : -k + 1]) S = u.size - 1 - d = P.shape[0] # dimension of spline + d = P.shape[0] # dimension of spline # Find multiplicity of the knot vector T b = BSplineBasis(k, T) - z = [k-1-b.continuity(t0) for t0 in b.knot_spans()] - + z = [k - 1 - b.continuity(t0) for t0 in b.knot_spans()] + # Step 1: Find Pt_i^j - Pt = np.zeros((d,n+1,k)) - Pt[:,:,0] = P - Pt = np.concatenate((Pt,Pt[:,0:periodic+1,:]),axis=1) - n += periodic+1 - - beta = np.cumsum(z[1:-1],dtype=int) - beta = np.insert(beta,0,0) # include the empty sum (=0) - for l in range(1,k): - for i in range(0,n+1-l): - if T[i+l] < T[i+k]: - Pt[:,i,l] = (Pt[:,i+1,l-1] - Pt[:,i,l-1])/(T[i+k]-T[i+l]) - - # Step 2: Create new knot vector Tb - nb = n + S*m - Tb = np.zeros(nb+m+k+1) - Tb[:k-1] = T[:k-1] - Tb[-k+1:] = T[-k+1:] - j = k-1 - for i in range(0,len(z)): - Tb[j:j+z[i]+m] = u[i] - j = j+z[i]+m - + Pt = np.zeros((d, n + 1, k)) + Pt[:, :, 0] = P + Pt = np.concatenate((Pt, Pt[:, 0 : periodic + 1, :]), axis=1) + n += periodic + 1 + + beta = np.cumsum(z[1:-1], dtype=int) + beta = np.insert(beta, 0, 0) # include the empty sum (=0) + for l in range(1, k): + for i in range(0, n + 1 - l): + if T[i + l] < T[i + k]: + Pt[:, i, l] = (Pt[:, i + 1, l - 1] - Pt[:, i, l - 1]) / (T[i + k] - T[i + l]) + + # Step 2: Create new knot vector Tb + nb = n + S * m + Tb = np.zeros(nb + m + k + 1) + Tb[: k - 1] = T[: k - 1] + Tb[-k + 1 :] = T[-k + 1 :] + j = k - 1 + for i in range(0, len(z)): + Tb[j : j + z[i] + m] = u[i] + j = j + z[i] + m + # Step 3: Find boundary values of Qt_i^j - Qt = np.zeros((d,nb+1,k)) - l_arr = np.array(range(1,k)) - alpha = np.cumprod((k-l_arr)/(k+m-l_arr)) - alpha = np.insert(alpha,0,1) # include the empty product (=1) - indices = range(0,k) - Qt[:,0,indices] = np.multiply(Pt[:,0,indices],np.reshape(alpha[indices],(1,1,k))) # (21) - for p in range(0,S): - indices = range(k-z[p],k) - Qt[:,beta[p]+p*m,indices] = np.multiply(Pt[:,beta[p],indices],np.reshape(alpha[indices],(1,1,z[p]))) # (22) - - for p in range(0,S): - idx = beta[p]+p*m - Qt[:,idx+1:m+idx+1,k-1] = np.repeat(Qt[:,idx:idx+1,k-1],m,axis=1) # (23) - + Qt = np.zeros((d, nb + 1, k)) + l_arr = np.array(range(1, k)) + alpha = np.cumprod((k - l_arr) / (k + m - l_arr)) + alpha = np.insert(alpha, 0, 1) # include the empty product (=1) + indices = range(0, k) + Qt[:, 0, indices] = np.multiply(Pt[:, 0, indices], np.reshape(alpha[indices], (1, 1, k))) # (21) + for p in range(0, S): + indices = range(k - z[p], k) + Qt[:, beta[p] + p * m, indices] = np.multiply( + Pt[:, beta[p], indices], np.reshape(alpha[indices], (1, 1, z[p])) + ) # (22) + + for p in range(0, S): + idx = beta[p] + p * m + Qt[:, idx + 1 : m + idx + 1, k - 1] = np.repeat(Qt[:, idx : idx + 1, k - 1], m, axis=1) # (23) + # Step 4: Find remaining values of Qt_i^j - for j in range(k-1,0,-1): - for i in range(0,nb): - if Tb[i+k+m] > Tb[i+j]: - Qt[:,i+1,j-1] = Qt[:,i,j-1] + (Tb[i+k+m] - Tb[i+j])*Qt[:,i,j] #(20) with Qt replacing Pt + for j in range(k - 1, 0, -1): + for i in range(0, nb): + if Tb[i + k + m] > Tb[i + j]: + Qt[:, i + 1, j - 1] = ( + Qt[:, i, j - 1] + (Tb[i + k + m] - Tb[i + j]) * Qt[:, i, j] + ) # (20) with Qt replacing Pt + + return Qt[:, :, 0] - return Qt[:,:,0] __all__ = [ - 'nutils', 'refinement', 'image', 'NACA', 'curve', 'smooth', - 'rotation_matrix', 'sections', 'section_from_index', 'section_to_index', - 'check_section', 'check_direction', 'ensure_flatlist', 'is_singleton', - 'ensure_listlike', 'rotate_local_x_axis', 'flip_and_move_plane_geometry', - 'reshape','raise_order_1D' + "nutils", + "refinement", + "image", + "NACA", + "curve", + "smooth", + "rotation_matrix", + "sections", + "section_from_index", + "section_to_index", + "check_section", + "check_direction", + "ensure_flatlist", + "is_singleton", + "ensure_listlike", + "rotate_local_x_axis", + "flip_and_move_plane_geometry", + "reshape", + "raise_order_1D", ] diff --git a/splipy/utils/bisect.py b/splipy/utils/bisect.py index f1c37c4..f679857 100644 --- a/splipy/utils/bisect.py +++ b/splipy/utils/bisect.py @@ -1,6 +1,9 @@ """Bisection algorithms.""" +# TODO: Py310 remove this module and use the built-in version. + + def insort_right(a, x, lo=0, hi=None, *, key=None): """Insert item x in list a, and keep it sorted assuming a is sorted. @@ -28,7 +31,7 @@ def bisect_right(a, x, lo=0, hi=None, *, key=None): """ if lo < 0: - raise ValueError('lo must be non-negative') + raise ValueError("lo must be non-negative") if hi is None: hi = len(a) # Note, the comparison uses "<" to match the @@ -65,6 +68,7 @@ def insort_left(a, x, lo=0, hi=None, *, key=None): lo = bisect_left(a, key(x), lo, hi, key=key) a.insert(lo, x) + def bisect_left(a, x, lo=0, hi=None, *, key=None): """Return the index where to insert item x in list a, assuming a is sorted. @@ -77,7 +81,7 @@ def bisect_left(a, x, lo=0, hi=None, *, key=None): """ if lo < 0: - raise ValueError('lo must be non-negative') + raise ValueError("lo must be non-negative") if hi is None: hi = len(a) # Note, the comparison uses "<" to match the diff --git a/splipy/utils/curve.py b/splipy/utils/curve.py index 671e2ee..47bbcc9 100644 --- a/splipy/utils/curve.py +++ b/splipy/utils/curve.py @@ -1,29 +1,42 @@ -__doc__ = 'Implementation of various curve utilities' +"Implementation of various curve utilities" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Sequence, Union import numpy as np +if TYPE_CHECKING: + from splipy.curve import Curve + from splipy.types import FArray, Scalars -def curve_length_parametrization(pts, normalize=False): + +def curve_length_parametrization( + pts: Union[Sequence[Scalars], FArray], + normalize: bool = False, + buffer: Optional[FArray] = None, +) -> FArray: """Calculate knots corresponding to a curvelength parametrization of a set of points. :param numpy.array pts: A set of points :param bool normalize: Whether to normalize the parametrization + :param numpy.array buffer: If given, the parametrization is stored in this array. :return: The parametrization :rtype: [float] """ - knots = [0.0] - for i in range(1, pts.shape[0]): - knots.append(knots[-1] + np.linalg.norm(pts[i,:] - pts[i-1,:])) + points = np.array(pts, dtype=float) + + knots = buffer if buffer is not None else np.empty((len(points) - 1,), dtype=float) + knots[:] = np.cumsum(np.linalg.norm(points[1:] - points[:-1], axis=1)) if normalize: - length = knots[-1] - knots = [k/length for k in knots] + knots /= knots[-1] return knots -def get_curve_points(curve): +def get_curve_points(curve: Curve) -> FArray: """Evaluate the curve in all its knots. :param curve: The curve diff --git a/splipy/utils/image.py b/splipy/utils/image.py index 7028c6b..96c62dc 100644 --- a/splipy/utils/image.py +++ b/splipy/utils/image.py @@ -1,19 +1,26 @@ -# -*- coding: utf-8 -*- +"Implementation of image based mesh generation." -__doc__ = 'Implementation of image based mesh generation.' +from __future__ import annotations +from itertools import chain from math import sqrt -import sys -import warnings +from typing import TYPE_CHECKING, Union, cast import numpy as np -from ..basis import BSplineBasis -from .. import curve_factory, surface_factory +from splipy import curve_factory, surface_factory +from splipy.basis import BSplineBasis +from splipy.types import FArray, IArray, Scalar +if TYPE_CHECKING: + from pathlib import Path -def get_corners(X, L=50, R=30, D=15): - """Detects corners of traced outlines using the SAM04 algorithm. + from splipy.curve import Curve + from splipy.surface import Surface + + +def get_corners(X: FArray, L: int = 50, R: float = 30, D: float = 15) -> IArray: + """Detect corners of traced outlines using the SAM04 algorithm. The outline is assumed to constitute a discrete closed curve where each point is included just once. Increasing `D` and `R` will give the same @@ -32,75 +39,66 @@ def get_corners(X, L=50, R=30, D=15): # Finds corner candidates d = np.zeros(n) - for i in range(1,n+1): - if i+L <= n: - k = i+L - index = np.array(range(i+1,k)) + for i in range(1, n + 1): + if i + L <= n: + k = i + L + index = np.arange(i + 1, k) else: - k = i+L-n - index = np.array(list(range(i+1,n+1)) + list(range(1,k))) + k = i + L - n + index = np.fromiter(chain(range(i + 1, n + 1), range(1, k)), dtype=int) - M = X[k-1,:]-X[i-1,:] + M = X[k - 1, :] - X[i - 1, :] if M[0] == 0: - dCand = abs(X[index-1,0]-X[i-1,0]) + dCand = abs(X[index - 1, 0] - X[i - 1, 0]) else: - m = float(M[1])/M[0] - dCand = abs(X[index-1,1]-m*X[index-1,0]+m*X[i-1,0]-X[i-1,1])/sqrt(m**2+1) + m = float(M[1]) / M[0] + dCand = abs(X[index - 1, 1] - m * X[index - 1, 0] + m * X[i - 1, 0] - X[i - 1, 1]) / sqrt( + m**2 + 1 + ) Y = max(dCand) I = np.argmax(dCand) - if Y > d[index[I]-1]: - d[index[I]-1] = Y + if d[index[I] - 1] < Y: + d[index[I] - 1] = Y - I = np.where(d > 0)[0] # Rejects candidates which do not meet the lower metric bound D. - index = d < D - index2 = d >= D - d[index] = 0 - C = np.array(range(n)) - C = C[index2] - + d[d < D] = 0 + C = np.arange(n)[d >= 0] # Rejects corners that are too close to a corner with larger metric. l = len(C) j = 0 - while j+1 < l: - if abs(C[j]-C[j+1]) <= R: - if d[C[j]] > d[C[j+1]]: - C = np.delete(C, j+1) - else: - C = np.delete(C, j) - l = l-1 + while j + 1 < l: + if abs(C[j] - C[j + 1]) <= R: + C = np.delete(C, j + 1) if d[C[j]] > d[C[j + 1]] else np.delete(C, j) + l = l - 1 else: - j = j+1 + j = j + 1 - if l > 0 and abs(C[0]+n-C[-1]) <=R: - if d[C[-1]] > d[C[0]]: - C = C[1:-1] - else: - C = C[0:-2] + if l > 0 and abs(C[0] + n - C[-1]) <= R: + C = C[1:-1] if d[C[-1]] > d[C[0]] else C[0:-2] # always include end-points in corner list, and never closer than 4 indices if 0 not in C: - C = np.insert(C,0,0) - if (n-1) not in C: - C = np.append(C,n-1) + C = np.insert(C, 0, 0) + if (n - 1) not in C: + C = np.append(C, n - 1) remove = [] - for i in range(1,len(C)-1): - if C[i]-C[i-1] < 5: + for i in range(1, len(C) - 1): + if C[i] - C[i - 1] < 5: remove.append(i) - if C[-1]-C[-2] < 5 and len(C)-2 not in remove: - remove.append(len(C)-2) + if C[-1] - C[-2] < 5 and len(C) - 2 not in remove: + remove.append(len(C) - 2) remove.reverse() for j in remove: - C = np.delete(C,j) + C = np.delete(C, j) return C -def image_curves(filename): +def image_curves(filename: Union[Path, str]) -> list[Curve]: """Generate B-spline curves corresponding to the edges in a black/white mask image. @@ -110,11 +108,11 @@ def image_curves(filename): """ import cv2 - im = cv2.imread(filename) + im = cv2.imread(str(filename)) # initialize image holders - imGrey = np.zeros((len(im), len(im[0])), np.uint8) - imBlack = np.zeros((len(im), len(im[0])), np.uint8) + imGrey = np.zeros((len(im), len(im[0])), dtype=np.uint8) + imBlack = np.zeros((len(im), len(im[0])), dtype=np.uint8) # convert to greyscale image cv2.cvtColor(im, cv2.COLOR_RGB2GRAY, imGrey) @@ -123,27 +121,23 @@ def image_curves(filename): cv2.threshold(imGrey, 128, 255, cv2.THRESH_BINARY, imBlack) # find contour curves in image - if cv2.__version__[0] == '3': - warnings.warn(FutureWarning('openCV v.3 will eventually be discontinued. Please update your version: \"pip install opencv-python --upgrade\"')) - [_, contours, _] = cv2.findContours(imBlack, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) - else: - [contours, _] = cv2.findContours(imBlack, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) + contours, _ = cv2.findContours(imBlack, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE) result = [] - for i in range(len(contours)-1): # for all contours (except the last one which is the edge) - pts = contours[i][:,0,:] # I have no idea why there needs to be a 0 here - for j in range(len(pts)): # invert y-axis since images are stored the other way around - pts[j][1] = len(im[0])-pts[j][1] + for i in range(len(contours) - 1): # for all contours (except the last one which is the edge) + pts = cast(FArray, contours[i][:, 0, :]) # I have no idea why there needs to be a 0 here + for j in range(len(pts)): # invert y-axis since images are stored the other way around + pts[j][1] = len(im[0]) - pts[j][1] corners = get_corners(pts) - if len(corners)>2: # start/stop tagged as corners. If any inner corners, then - pts = np.roll(pts, -corners[1],0) # rearrange points so start/stop falls at a natural corner. - corners = get_corners(pts) # recompute corners, since previous sem might be smooth + if len(corners) > 2: # start/stop tagged as corners. If any inner corners, then + pts = np.roll(pts, -corners[1], 0) # rearrange points so start/stop falls at a natural corner. + corners = get_corners(pts) # recompute corners, since previous sem might be smooth n = len(pts) - parpt = list(range(n)) + parpt: list[float] = list(range(n)) for i in range(n): - parpt[i] = float(parpt[i]) / (n-1) + parpt[i] = float(parpt[i]) / (n - 1) # the choice of knot vector is a tricky one. We'll go with the following strategy: # - cubic, p=3 curve @@ -155,35 +149,35 @@ def image_curves(filename): # - up to a max of 100(ish) control points for large models # start off with a uniform(ish) knot vector - knot = [] - nStart = min(n//10, 90) - for i in range(nStart+1): - knot.append(int(1.0*i*(n-1)/nStart)) + knot: list[Scalar] = [] + nStart = min(n // 10, 90) + for i in range(nStart + 1): + knot.append(int(1.0 * i * (n - 1) / nStart)) c = corners.tolist() - knot = sorted(list(set(knot+c))) # unique sorted list + knot = sorted(set(knot + c)) # unique sorted list # make sure there is at least one knot between corners newKnot = [] - for i in range(len(c)-1): - if knot.index(c[i+1])-knot.index(c[i]) == 1: - newKnot.append((c[i+1]+c[i])/2) + for i in range(len(c) - 1): + if knot.index(c[i + 1]) - knot.index(c[i]) == 1: + newKnot.append((c[i + 1] + c[i]) / 2) knot = sorted(knot + newKnot) # make sure no two knots are too close (typical corners which do this) - for i in range(1,len(knot)-1): + for i in range(1, len(knot) - 1): if knot[i] not in c: - knot[i] = (knot[i-1]+knot[i+1])/2.0 + knot[i] = (knot[i - 1] + knot[i + 1]) / 2.0 # make C^0 at corners and C^-1 at endpoints by duplicating knots - knot = sorted(knot + c + c + [0,n-1]) # both c and knot contains a copy of the endpoints + knot = sorted(knot + c + c + [0, n - 1]) # both c and knot contains a copy of the endpoints # make it span [0,1] instead of [0,n-1] for i in range(len(knot)): - knot[i] /= float(n-1) + knot[i] /= float(n - 1) # make it periodic since these are all closed curves - knot[0] -= knot[-1] - knot[-5] - knot[-1] += knot[4] - knot[1] + knot[0] -= knot[-1] - knot[-5] + knot[-1] += knot[4] - knot[1] basis = BSplineBasis(4, knot, 0) @@ -192,7 +186,10 @@ def image_curves(filename): return result -def image_height(filename, N=[30,30], p=[4,4]): + +def image_height( + filename: Union[str, Path], N: tuple[int, int] = (30, 30), p: tuple[int, int] = (4, 4) +) -> Surface: """Generate a B-spline surface approximation given by the heightmap in a grayscale image. @@ -205,41 +202,41 @@ def image_height(filename, N=[30,30], p=[4,4]): import cv2 - im = cv2.imread(filename) + im = cv2.imread(str(filename)) - width = len(im[0]) + width = len(im[0]) height = len(im) # initialize image holder - imGrey = np.zeros((height, width), np.uint8) + imGrey = np.zeros((height, width), dtype=np.uint8) # convert to greyscale image cv2.cvtColor(im, cv2.COLOR_RGB2GRAY, imGrey) # guess uniform evaluation points and knot vectors - u = list(range(width)) - v = list(range(height)) - knot1 = [0]*(p[0]-1) + list(range(N[0]-p[0]+2)) + [N[0]-p[0]+1]*(p[0]-1) - knot2 = [0]*(p[1]-1) + list(range(N[1]-p[1]+2)) + [N[1]-p[1]+1]*(p[1]-1) + u: list[float] = list(range(width)) + v: list[float] = list(range(height)) + knot1: list[float] = [0.0] * (p[0] - 1) + list(range(N[0] - p[0] + 2)) + [N[0] - p[0] + 1] * (p[0] - 1) + knot2: list[float] = [0.0] * (p[1] - 1) + list(range(N[1] - p[1] + 2)) + [N[1] - p[1] + 1] * (p[1] - 1) # normalize all values to be in range [0, 1] - u = [float(i)/u[-1] for i in u] - v = [float(i)/v[-1] for i in v] - knot1 = [float(i)/knot1[-1] for i in knot1] - knot2 = [float(i)/knot2[-1] for i in knot2] + u = [float(i) / u[-1] for i in u] + v = [float(i) / v[-1] for i in v] + knot1 = [float(i) / knot1[-1] for i in knot1] + knot2 = [float(i) / knot2[-1] for i in knot2] # flip and reverse image so coordinate (0,0) is at lower-left corner - imGrey = imGrey.T / 255.0 - imGrey = np.flip(imGrey, axis=1) - x,y = np.meshgrid(u,v, indexing='ij') - pts = np.stack([x,y,imGrey], axis=2) + imGreyF = np.flip(imGrey.T / 255.0, axis=1) + x, y = np.meshgrid(u, v, indexing="ij") + pts = np.stack([x, y, imGreyF], axis=2) basis1 = BSplineBasis(p[0], knot1) basis2 = BSplineBasis(p[1], knot2) - return surface_factory.least_square_fit(pts,[basis1, basis2], [u,v]) + return surface_factory.least_square_fit(pts, [basis1, basis2], [u, v]) + -def image_convex_surface(filename): +def image_convex_surface(filename: Union[Path, str]) -> Surface: """Generate a single B-spline surface corresponding to convex black domain of a black/white mask image. The algorithm traces the boundary and searches for 4 natural corner points. It will then generate 4 boundary curves which @@ -250,38 +247,40 @@ def image_convex_surface(filename): :rtype: :class:`splipy.Surface` """ # generate boundary curve - crv = image_curves(filename) + curves = image_curves(filename) # error test input - if len(crv) != 1: - raise RuntimeError('Error: image_convex_surface expects a single closed curve. Multiple curves detected') + if len(curves) != 1: + raise RuntimeError( + "Error: image_convex_surface expects a single closed curve. Multiple curves detected" + ) - crv = crv[0] + crv = curves[0] # parametric value of corner candidates. These are all in the range [0,1] and both 0 and 1 is present kinks = crv.get_kinks() # generate 4 corners if len(kinks) == 2: - corners = [0, .25, .5, .75] + corners = [0.0, 0.25, 0.5, 0.75] elif len(kinks) == 3: - corners = [0, (0+kinks[1])/2, kinks[1], (1+kinks[1])/2] + corners = [0, (0 + kinks[1]) / 2, kinks[1], (1 + kinks[1]) / 2] elif len(kinks) == 4: - if kinks[1]-kinks[0] > kinks[2]-kinks[1] and kinks[1]-kinks[0] > kinks[3]-kinks[2]: - corners = [0, (kinks[0]+kinks[1])/2] + kinks[1:3] - elif kinks[2]-kinks[1] > kinks[3]-kinks[2]: - corners = [0, kinks[1], (kinks[1]+kinks[2])/2], kinks[2] + if kinks[1] - kinks[0] > kinks[2] - kinks[1] and kinks[1] - kinks[0] > kinks[3] - kinks[2]: + corners = [0, (kinks[0] + kinks[1]) / 2] + kinks[1:3] + elif kinks[2] - kinks[1] > kinks[3] - kinks[2]: + corners = [0, kinks[1], (kinks[1] + kinks[2]) / 2, kinks[2]] else: - corners = [0] + kinks[1:3] + [(kinks[2]+kinks[3])/2] + corners = [0] + kinks[1:3] + [(kinks[2] + kinks[3]) / 2] else: while len(kinks) > 5: - max_span = 0 + max_span = 0.0 max_span_i = 0 - for i in range(1,len(kinks)-1): - max_span = max(max_span, kinks[i+1]-kinks[i-1]) + for i in range(1, len(kinks) - 1): + max_span = max(max_span, kinks[i + 1] - kinks[i - 1]) max_span_i = i del kinks[max_span_i] corners = kinks[0:4] diff --git a/splipy/utils/nutils.py b/splipy/utils/nutils.py index 04245e5..f8e3a11 100644 --- a/splipy/utils/nutils.py +++ b/splipy/utils/nutils.py @@ -1,40 +1,58 @@ -__doc__ = 'Implementation of convenience methods with respect to nutils integration.' +from __future__ import annotations + +__doc__ = "Implementation of convenience methods with respect to nutils integration." + +from typing import TYPE_CHECKING import numpy as np -from ..curve import Curve -from ..surface import Surface -from ..volume import Volume +from splipy.curve import Curve +from splipy.surface import Surface +from splipy.volume import Volume + +if TYPE_CHECKING: + from nutils import function, mesh + + from splipy.splineobject import SplineObject + from splipy.types import FArray -def controlpoints(spline): - """ Return controlpoints according to nutils ordering """ +def controlpoints(spline: SplineObject) -> FArray: + """Return controlpoints according to nutils ordering.""" n = len(spline) dim = spline.dimension if isinstance(spline, Curve): - return np.reshape(spline[:,:] , (n, dim), order='F') - elif isinstance(spline, Surface): - return np.reshape(spline[:,:,:].swapaxes(0,1) , (n, dim), order='F') - elif isinstance(spline, Volume): - return np.reshape(spline[:,:,:,:].swapaxes(0,2), (n, dim), order='F') - raise RuntimeError('Non-spline argument detected') - -def multiplicities(spline): - """ Returns the multiplicity of the knots at all knot values as a 2D array for - all parametric directions, for all knots """ - return [[spline.order(d) - spline.bases[d].continuity(k) - 1 for k in spline.knots(d)] for d in range(spline.pardim)] - -def degree(spline): - """ Returns polynomial degree (splipy order - 1) for all parametric directions """ - return [p-1 for p in spline.order()] - -def splipy_to_nutils(spline): - """ Returns nutils domain and geometry object for spline mapping given by the argument """ - from nutils import mesh, function + return np.reshape(spline[:, :], (n, dim), order="F") + if isinstance(spline, Surface): + return np.reshape(spline[:, :, :].swapaxes(0, 1), (n, dim), order="F") + if isinstance(spline, Volume): + return np.reshape(spline[:, :, :, :].swapaxes(0, 2), (n, dim), order="F") + raise RuntimeError("Non-spline argument detected") + + +def multiplicities(spline: SplineObject) -> list[list[int]]: + """Return the multiplicity of the knots at all knot values as a 2D array for + all parametric directions, for all knots. + """ + return [ + [spline.order(d) - spline.bases[d].continuity(k) - 1 for k in spline.knots(d)] + for d in range(spline.pardim) + ] + + +def degree(spline: SplineObject) -> list[int]: + """Returns polynomial degree (splipy order - 1) for all parametric directions""" + return [p - 1 for p in spline.order()] + + +def splipy_to_nutils(spline: SplineObject) -> tuple[mesh.Domain, function.Function]: + """Returns nutils domain and geometry object for spline mapping given by the argument""" + from nutils import function, mesh + domain, geom = mesh.rectilinear(spline.knots()) - cp = controlpoints(spline) - basis = domain.basis('spline', degree=degree(spline), knotmultiplicities=multiplicities(spline)) - geom = function.matmat(basis, cp) - #TODO: add correct behaviour for rational and/or periodic geometries - return domain, geom + cp = controlpoints(spline) + basis = domain.basis("spline", degree=degree(spline), knotmultiplicities=multiplicities(spline)) + geom = function.matmat(basis, cp) + # TODO(Kjetil): add correct behaviour for rational and/or periodic geometries + return domain, geom diff --git a/splipy/utils/refinement.py b/splipy/utils/refinement.py index 0fc979f..445bce8 100644 --- a/splipy/utils/refinement.py +++ b/splipy/utils/refinement.py @@ -1,19 +1,31 @@ -__doc__ = 'Implementation of various refinement schemes.' +"""Implementation of various refinement schemes.""" -from math import atan, pi, tan +from __future__ import annotations + +from math import atan, tan +from typing import TYPE_CHECKING, Sequence, TypeVar, Union import numpy as np -from . import ensure_listlike, check_direction +from splipy.splineobject import SplineObject + +from . import check_direction, ensure_listlike + +if TYPE_CHECKING: + from splipy.types import Direction, FArray, Scalar + -# TODO: put control over these tolerances somewhere. Modstate in splipy seems -# to be the place for it, but we can't let splipy.utils influence the -# structure of splipy. -def knot_exists(existing_knots, new_knot): +# TODO(Kjetil): put control over these tolerances somewhere. Modstate in splipy seems +# to be the place for it, but we can't let splipy.utils influence the +# structure of splipy. +def knot_exists(existing_knots: FArray, new_knot: Scalar) -> np.bool_: return np.any(np.isclose(existing_knots, new_knot, atol=1e-7, rtol=1e-10)) -def geometric_refine(obj, alpha, n, direction=0, reverse=False): +T = TypeVar("T", bound=SplineObject) + + +def geometric_refine(obj: T, alpha: float, n: int, direction: Direction = 0, reverse: bool = False) -> T: """geometric_refine(obj, alpha, n, [direction=0], [reverse=False]) Refine a spline object by making a geometric distribution of element sizes. @@ -26,8 +38,8 @@ def geometric_refine(obj, alpha, n, direction=0, reverse=False): :param bool reverse: Set to `True` to refine towards the other end """ # some error tests on input - if n<=0: - raise ValueError('n should be greater than 0') + if n <= 0: + raise ValueError("n should be greater than 0") direction = check_direction(direction, obj.pardim) if reverse: @@ -36,27 +48,27 @@ def geometric_refine(obj, alpha, n, direction=0, reverse=False): # fetch knots knots = obj.knots() knot_start = knots[direction][0] - knot_end = knots[direction][-1] + knot_end = knots[direction][-1] dk = knot_end - knot_start # evaluate the factors - n = n+1 # redefine n to be knot spans instead of new internal knots + n = n + 1 # redefine n to be knot spans instead of new internal knots totProd = 1.0 - totSum = 0.0 + totSum = 0.0 for i in range(n): - totSum += totProd + totSum += totProd totProd *= alpha d1 = 1.0 / totSum knot = d1 # compute knot locations new_knots = [] - for i in range(n-1): - k = knot_start + knot*dk + for i in range(n - 1): + k = knot_start + knot * dk if not knot_exists(knots[direction], k): new_knots.append(k) - knot += alpha*d1 - d1 *= alpha + knot += alpha * d1 + d1 *= alpha # do the actual knot insertion obj.insert_knot(new_knots, direction) @@ -65,7 +77,8 @@ def geometric_refine(obj, alpha, n, direction=0, reverse=False): obj.reverse(direction) return obj -def center_refine(obj, S, n, direction=0): + +def center_refine(obj: T, S: float, n: int, direction: Direction = 0) -> T: """center_refine(obj, S, n, [direction=0]) Refine an object towards the center in a direction, by sampling an @@ -78,25 +91,25 @@ def center_refine(obj, S, n, direction=0): :param direction: The direction to refine in """ # some error tests on input - if n<=0: - raise ValueError('n should be greater than 0') - assert 0 < S < np.pi/2 + if n <= 0: + raise ValueError("n should be greater than 0") + assert 0 < S < np.pi / 2 direction = check_direction(direction, obj.pardim) # fetch knots knots = obj.knots() knot_start = knots[direction][0] - knot_end = knots[direction][-1] + knot_end = knots[direction][-1] dk = knot_end - knot_start # compute knot locations new_knots = [] - max_tan = tan(S) - for i in range(1,n+1): - xi = -1.0 + 2.0*i/(n+1) + max_tan = tan(S) + for i in range(1, n + 1): + xi = -1.0 + 2.0 * i / (n + 1) xi *= S - k = knot_start + (tan(xi)+max_tan)/2/max_tan*dk + k = knot_start + (tan(xi) + max_tan) / 2 / max_tan * dk if not knot_exists(knots[direction], k): new_knots.append(k) @@ -105,7 +118,7 @@ def center_refine(obj, S, n, direction=0): return obj -def edge_refine(obj, S, n, direction=0): +def edge_refine(obj: T, S: float, n: int, direction: Direction = 0) -> T: """edge_refine(obj, S, n, [direction=0]) Refine an object towards both edges in a direction, by sampling an @@ -118,24 +131,24 @@ def edge_refine(obj, S, n, direction=0): :param direction: The direction to refine in """ # some error tests on input - if n<=0: - raise ValueError('n should be greater than 0') + if n <= 0: + raise ValueError("n should be greater than 0") direction = check_direction(direction, obj.pardim) # fetch knots knots = obj.knots() knot_start = knots[direction][0] - knot_end = knots[direction][-1] + knot_end = knots[direction][-1] dk = knot_end - knot_start # compute knot locations new_knots = [] - max_atan = atan(S) - for i in range(1,n+1): - xi = -1.0 + 2.0*i/(n+1) + max_atan = atan(S) + for i in range(1, n + 1): + xi = -1.0 + 2.0 * i / (n + 1) xi *= S - k = knot_start + (atan(xi)+max_atan)/2/max_atan*dk + k = knot_start + (atan(xi) + max_atan) / 2 / max_atan * dk if not knot_exists(knots[direction], k): new_knots.append(k) @@ -144,19 +157,19 @@ def edge_refine(obj, S, n, direction=0): return obj -def _splitvector(len, parts): +def _splitvector(len: int, parts: int) -> list[int]: delta = len // parts sizes = [delta for i in range(parts)] - remainder = len-parts*delta - for i in range(parts-remainder+1, parts): - sizes[i] = sizes[i]+1 + remainder = len - parts * delta + for i in range(parts - remainder + 1, parts): + sizes[i] = sizes[i] + 1 result = [0] - for i in range(1,parts): - result.append(sizes[i]+result[i-1]) + for i in range(1, parts): + result.append(sizes[i] + result[i - 1]) return result -def subdivide(objs, n): +def subdivide(objs: Sequence[T], n: Union[int, Sequence[int]]) -> list[T]: """Subdivide a list of objects by splitting them up along existing knot lines. The resulting partition will roughly the same number of elements on all pieces. By splitting along *n* lines, we generate *n* + 1 new blocks. @@ -170,19 +183,18 @@ def subdivide(objs, n): :return: New objects :rtype: [:class:`splipy.SplineObject`] """ - pardim = objs[0].pardim # 1 for curves, 2 for surfaces, 3 for volumes + pardim = objs[0].pardim # 1 for curves, 2 for surfaces, 3 for volumes n = ensure_listlike(n, pardim) - result = objs + result = list(objs) for d in range(pardim): # split all objects so far along direction d - new_results = [] + new_results: list[T] = [] for obj in result: - splitting_points = [obj.knots(d)[i] for i in _splitvector(len(obj.knots(d)), n[d]+1)] - new_results += obj.split(splitting_points[1:], d) + splitting_points = [obj.knots(d)[i] for i in _splitvector(len(obj.knots(d)), n[d] + 1)] + new_results += obj.split_many(splitting_points[1:], d) # only keep the smallest pieces in our result list result = new_results return result - diff --git a/splipy/utils/smooth.py b/splipy/utils/smooth.py index 8402398..be4df6b 100644 --- a/splipy/utils/smooth.py +++ b/splipy/utils/smooth.py @@ -1,12 +1,20 @@ -__doc__ = 'Implementation of various smoothing operations on a per-controlpoint level.' +"Implementation of various smoothing operations on a per-controlpoint level." + +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional -from scipy import ndimage import numpy as np +from scipy import ndimage from . import check_direction +if TYPE_CHECKING: + from splipy.splineobject import SplineObject + from splipy.types import Direction + -def smooth(obj, comp=None): +def smooth(obj: SplineObject, comp: Optional[Direction] = None) -> None: """Smooth an object by setting the interior control points to the average of itself and all neighbours (e.g. 9 for surfaces, 27 for volumes). The edges are kept unchanged, and any rational weights are kept unchanged. @@ -17,32 +25,32 @@ def smooth(obj, comp=None): :type comp: int or None """ n = obj.shape - if comp is not None: - comp = check_direction(comp, len(obj)) - averaging_mask = np.ones([3]*len(n)+[1]) + averaging_mask = np.ones([3] * len(n) + [1]) averaging_mask /= averaging_mask.size - new_controlpoints = ndimage.convolve(obj.controlpoints, averaging_mask, mode='wrap') + new_controlpoints = ndimage.convolve(obj.controlpoints, averaging_mask, mode="wrap") # build up the indexing for the domain 'interior'. This would be # controlpoints[1:-1, 1:-1 ,:] for non-rational surface # controlpoints[ : , 1:-1 ,:] for surfaces which is periodic in 'u' # controlpoints[1:-1, 1:-1 ,:-1] for rational surfaces # controlpoints[1:-1, 1:-1 , 1:-1, :] for non-rational volumes - # controlpoints[ : , : , : , :] for non-rational volumes which is periodic in all three parametric directions + # controlpoints[ : , : , : , :] for non-rational volumes + # which are periodic in all three parametric directions interior = [] for pardim in range(len(n)): if obj.periodic(pardim): - interior.append(slice(None,None,None)) + interior.append(slice(None, None, None)) else: - interior.append(slice(1,-1,None)) + interior.append(slice(1, -1, None)) if obj.rational: - interior.append(slice(0,-1,None)) + interior.append(slice(0, -1, None)) elif comp is not None: - interior.append(slice(comp,comp+1,None)) + cix = check_direction(comp, obj.dimension) + interior.append(slice(cix, cix + 1, None)) else: - interior.append(slice(None,None,None)) + interior.append(slice(None, None, None)) - interior = tuple(interior) - obj.controlpoints[interior] = new_controlpoints[interior] + ix = tuple(interior) + obj.controlpoints[ix] = new_controlpoints[ix] diff --git a/splipy/volume.py b/splipy/volume.py index 21b6cab..3a23eba 100644 --- a/splipy/volume.py +++ b/splipy/volume.py @@ -1,14 +1,16 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from itertools import chain +from typing import Any, Optional, Sequence, Union, cast import numpy as np from .basis import BSplineBasis +from .curve import Curve from .splineobject import SplineObject -from .utils import ensure_listlike, check_direction, sections +from .surface import Surface +from .utils import ensure_listlike, sections -__all__ = ['Volume'] +__all__ = ["Volume"] class Volume(SplineObject): @@ -18,8 +20,16 @@ class Volume(SplineObject): _intended_pardim = 3 - def __init__(self, basis1=None, basis2=None, basis3=None, controlpoints=None, rational=False, **kwargs): - """ Construct a volume with the given basis and control points. + def __init__( + self, + basis1: Optional[BSplineBasis] = None, + basis2: Optional[BSplineBasis] = None, + basis3: Optional[BSplineBasis] = None, + controlpoints: Any = None, + rational: bool = False, + raw: bool = False, + ) -> None: + """Construct a volume with the given basis and control points. The default is to create a linear one-element mapping from and to the unit cube. @@ -33,9 +43,11 @@ def __init__(self, basis1=None, basis2=None, basis3=None, controlpoints=None, ra control points are interpreted as pre-multiplied with the weight, which is the last coordinate) """ - super(Volume, self).__init__([basis1, basis2, basis3], controlpoints, rational, **kwargs) + super().__init__([basis1, basis2, basis3], controlpoints, rational=rational, raw=raw) - def edges(self): + def edges( + self, + ) -> tuple[Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve]: """Return the twelve edges of this volume in order: - umin, vmin @@ -54,35 +66,59 @@ def edges(self): :return: Edges :rtype: (Curve) """ - return tuple(self.section(*args) for args in sections(3, 1)) - - def faces(self): + return cast( + tuple[Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve, Curve], + tuple(self.section(*args) for args in sections(3, 1)), + ) + + def faces( + self, + ) -> tuple[ + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + ]: """Return the six faces of this volume in order: umin, umax, vmin, vmax, wmin, wmax. :return: Boundary faces :rtype: (Surface) """ - boundary_faces = [self.section(*args) for args in sections(3, 2)] - for i,b in enumerate(self.bases): + boundary_faces: list[Optional[Surface]] + boundary_faces = [self.section(*args) for args in sections(3, 2)] # type: ignore[misc] + for i, b in enumerate(self.bases): if b.periodic > -1: - boundary_faces[2*i ] = None - boundary_faces[2*i+1] = None - return tuple(boundary_faces) + boundary_faces[2 * i] = None + boundary_faces[2 * i + 1] = None + return cast( + tuple[ + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + Optional[Surface], + ], + tuple(boundary_faces), + ) + + def volume(self) -> float: + """Compute the volume of the object in geometric space.""" - def volume(self): - """ Computes the volume of the object in geometric space """ # fetch integration points - (x1,w1) = np.polynomial.legendre.leggauss(self.order(0)+1) - (x2,w2) = np.polynomial.legendre.leggauss(self.order(1)+1) - (x3,w3) = np.polynomial.legendre.leggauss(self.order(2)+1) + (x1, w1) = np.polynomial.legendre.leggauss(self.order(0) + 1) + (x2, w2) = np.polynomial.legendre.leggauss(self.order(1) + 1) + (x3, w3) = np.polynomial.legendre.leggauss(self.order(2) + 1) # map points to parametric coordinates (and update the weights) - (knots1,knots2,knots3) = self.knots() - u = np.array([ (x1+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots1[:-1], knots1[1:]) ]) - w1 = np.array([ w1/2*(t1-t0) for t0,t1 in zip(knots1[:-1], knots1[1:]) ]) - v = np.array([ (x2+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots2[:-1], knots2[1:]) ]) - w2 = np.array([ w2/2*(t1-t0) for t0,t1 in zip(knots2[:-1], knots2[1:]) ]) - w = np.array([ (x3+1)/2*(t1-t0)+t0 for t0,t1 in zip(knots3[:-1], knots3[1:]) ]) - w3 = np.array([ w3/2*(t1-t0) for t0,t1 in zip(knots3[:-1], knots3[1:]) ]) + (knots1, knots2, knots3) = self.knots() + u = np.array([(x1 + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots1[:-1], knots1[1:])]) + w1 = np.array([w1 / 2 * (t1 - t0) for t0, t1 in zip(knots1[:-1], knots1[1:])]) + v = np.array([(x2 + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots2[:-1], knots2[1:])]) + w2 = np.array([w2 / 2 * (t1 - t0) for t0, t1 in zip(knots2[:-1], knots2[1:])]) + w = np.array([(x3 + 1) / 2 * (t1 - t0) + t0 for t0, t1 in zip(knots3[:-1], knots3[1:])]) + w3 = np.array([w3 / 2 * (t1 - t0) for t0, t1 in zip(knots3[:-1], knots3[1:])]) # wrap everything to vectors u = np.ndarray.flatten(u) @@ -93,18 +129,24 @@ def volume(self): w3 = np.ndarray.flatten(w3) # compute all quantities of interest (i.e. the jacobian) - du = self.derivative(u,v,w, d=(1,0,0)) - dv = self.derivative(u,v,w, d=(0,1,0)) - dw = self.derivative(u,v,w, d=(0,0,1)) - - J = du[:,:,:,0] * np.cross(dv[:,:,:,1:], dw[:,:,:,1:] ) - \ - du[:,:,:,1] * np.cross(dv[:,:,:,0::2], dw[:,:,:,0::2]) + \ - du[:,:,:,2] * np.cross(dv[:,:,:,:-1], dw[:,:,:,:-1] ) - - return np.abs(J).dot(w3).dot(w2).dot(w1) - - def rebuild(self, p, n): - """ Creates an approximation to this volume by resampling it using + du = self.derivative(u, v, w, d=(1, 0, 0)) + dv = self.derivative(u, v, w, d=(0, 1, 0)) + dw = self.derivative(u, v, w, d=(0, 0, 1)) + + J = ( + du[:, :, :, 0] * np.cross(dv[:, :, :, 1:], dw[:, :, :, 1:]) + - du[:, :, :, 1] * np.cross(dv[:, :, :, 0::2], dw[:, :, :, 0::2]) + + du[:, :, :, 2] * np.cross(dv[:, :, :, :-1], dw[:, :, :, :-1]) + ) + + return cast(float, np.abs(J).dot(w3).dot(w2).dot(w1)) + + def rebuild( + self, + p: Union[int, Sequence[int]], + n: Union[int, Sequence[int]], + ) -> Volume: + """Create an approximation to this volume by resampling it using uniform knot vectors of order *p* with *n* control points. :param (int) p: Tuple of polynomial discretization order in each direction @@ -128,7 +170,7 @@ def rebuild(self, p, n): basis[i].normalize() t0 = old_basis[i].start() t1 = old_basis[i].end() - basis[i] *= (t1 - t0) + basis[i] *= t1 - t0 basis[i] += t0 # fetch evaluation points and evaluate basis functions @@ -150,16 +192,16 @@ def rebuild(self, p, n): # return new resampled curve return Volume(basis[0], basis[1], basis[2], cp) - def __repr__(self): - result = str(self.bases[0]) + '\n' - result += str(self.bases[1]) + '\n' - result += str(self.bases[2]) + '\n' + def __repr__(self) -> str: + result = str(self.bases[0]) + "\n" + result += str(self.bases[1]) + "\n" + result += str(self.bases[2]) + "\n" # print legacy controlpoint enumeration n1, n2, n3, dim = self.controlpoints.shape for k in range(n3): for j in range(n2): for i in range(n1): - result += str(self.controlpoints[i, j, k, :]) + '\n' + result += str(self.controlpoints[i, j, k, :]) + "\n" return result get_derivative_volume = SplineObject.get_derivative_spline diff --git a/splipy/volume_factory.py b/splipy/volume_factory.py index 35371f9..8047ce1 100644 --- a/splipy/volume_factory.py +++ b/splipy/volume_factory.py @@ -1,24 +1,43 @@ -# -*- coding: utf-8 -*- - """Handy utilities for creating volumes.""" -from math import pi, sqrt, atan2 +from __future__ import annotations + +from itertools import chain, repeat +from math import atan2, pi, sqrt +from typing import TYPE_CHECKING, Literal, Optional, Sequence, Union, cast, overload import numpy as np +from . import curve_factory, surface_factory from .basis import BSplineBasis from .surface import Surface +from .utils import rotate_local_x_axis +from .utils.curve import curve_length_parametrization from .volume import Volume -from .utils import flip_and_move_plane_geometry, rotate_local_x_axis -from . import curve_factory, surface_factory -__all__ = ['cube', 'sphere', 'revolve', 'cylinder', 'extrude', 'edge_surfaces', - 'loft', 'interpolate', 'least_square_fit'] +if TYPE_CHECKING: + from .curve import Curve + from .types import FArray, Scalar, Scalars + +__all__ = [ + "cube", + "sphere", + "revolve", + "cylinder", + "extrude", + "edge_surfaces", + "loft", + "interpolate", + "least_square_fit", +] -def cube(size=1, lower_left=(0,0,0)): - """ Create a cube with parmetric origin at *(0,0,0)*. +def cube( + size: Union[Scalar, tuple[Scalar, Scalar, Scalar]] = 1, + lower_left: Scalars = (0, 0, 0), +) -> Volume: + """Create a cube with parmetric origin at *(0,0,0)*. :param float size: Size(s), either a single scalar or a tuple of scalars per axis :param array-like lower_left: local origin, the lower bottom left corner of the cube @@ -26,12 +45,20 @@ def cube(size=1, lower_left=(0,0,0)): :rtype: Volume """ result = Volume() - result.scale(size) + if isinstance(size, tuple): + result.scale(*size) + else: + result.scale(size) result += lower_left return result -def sphere(r=1, center=(0,0,0), type='radial'): - """ Create a solid sphere + +def sphere( + r: Scalar = 1, + center: Scalars = (0, 0, 0), + type: Literal["radial", "square"] = "radial", +) -> Volume: + """Create a solid sphere :param float r: Radius :param array-like center: Local origin of the sphere @@ -39,72 +66,85 @@ def sphere(r=1, center=(0,0,0), type='radial'): :return: A solid ball :rtype: Volume """ - if type == 'radial': - shell = surface_factory.sphere(r, center) - midpoint = shell*0 + center + + if type == "radial": + shell = surface_factory.sphere(r, center) + midpoint = shell * 0 + center return edge_surfaces(shell, midpoint) - elif type == 'square': + + if type == "square": # based on the work of James E.Cobb: "Tiling the Sphere with Rational Bezier Patches" # University of Utah, July 11, 1988. UUCS-88-009 b = BSplineBasis(order=5) sr2 = sqrt(2) sr3 = sqrt(3) sr6 = sqrt(6) - cp = [[ -4*(sr3-1), 4*(1-sr3), 4*(1-sr3), 4*(3-sr3) ], # row 0 - [ -sr2 , sr2*(sr3-4), sr2*(sr3-4), sr2*(3*sr3-2)], - [ 0 , 4./3*(1-2*sr3), 4./3*(1-2*sr3),4./3*(5-sr3) ], - [ sr2 , sr2*(sr3-4), sr2*(sr3-4), sr2*(3*sr3-2)], - [ 4*(sr3-1), 4*(1-sr3), 4*(1-sr3), 4*(3-sr3) ], - [ -sr2*(4-sr3), -sr2, sr2*(sr3-4), sr2*(3*sr3-2)], # row 1 - [ -(3*sr3-2)/2, (2-3*sr3)/2, -(sr3+6)/2, (sr3+6)/2], - [ 0 , sr2*(2*sr3-7)/3, -5*sr6/3, sr2*(sr3+6)/3], - [ (3*sr3-2)/2, (2-3*sr3)/2, -(sr3+6)/2, (sr3+6)/2], - [ sr2*(4-sr3), -sr2, sr2*(sr3-4), sr2*(3*sr3-2)], - [ -4./3*(2*sr3-1), 0, 4./3*(1-2*sr3), 4*(5-sr3)/3], # row 2 - [-sr2/3*(7-2*sr3), 0, -5*sr6/3, sr2*(sr3+6)/3], - [ 0 , 0, 4*(sr3-5)/3, 4*(5*sr3-1)/9], - [ sr2/3*(7-2*sr3), 0, -5*sr6/3, sr2*(sr3+6)/3], - [ 4./3*(2*sr3-1), 0, 4./3*(1-2*sr3), 4*(5-sr3)/3], - [ -sr2*(4-sr3), sr2, sr2*(sr3-4), sr2*(3*sr3-2)], # row 3 - [ -(3*sr3-2)/2, -(2-3*sr3)/2, -(sr3+6)/2, (sr3+6)/2], - [ 0 ,-sr2*(2*sr3-7)/3, -5*sr6/3, sr2*(sr3+6)/3], - [ (3*sr3-2)/2, -(2-3*sr3)/2, -(sr3+6)/2, (sr3+6)/2], - [ sr2*(4-sr3), sr2, sr2*(sr3-4), sr2*(3*sr3-2)], - [ -4*(sr3-1), -4*(1-sr3), 4*(1-sr3), 4*(3-sr3) ], # row 4 - [ -sr2 , -sr2*(sr3-4), sr2*(sr3-4), sr2*(3*sr3-2)], - [ 0 , -4./3*(1-2*sr3), 4./3*(1-2*sr3),4./3*(5-sr3) ], - [ sr2 , -sr2*(sr3-4), sr2*(sr3-4), sr2*(3*sr3-2)], - [ 4*(sr3-1), -4*(1-sr3), 4*(1-sr3), 4*(3-sr3) ]] - wmin = Surface(b,b,cp, rational=True) - wmax = wmin.clone().mirror([0,0,1]) - vmax = wmin.clone().rotate(pi/2, [1,0,0]) - vmin = vmax.clone().mirror([0,1,0]) - umax = vmin.clone().rotate(pi/2, [0,0,1]) - umin = umax.clone().mirror([1,0,0]) - # ideally I would like to call edge_surfaces() now, but that function - # does not work with rational surfaces, so we'll just manually try - # and add some inner controlpoints - cp = np.zeros((5,5,5,4)) - cp[ :, :, 0,:] = wmin[:,:,:] - cp[ :, :,-1,:] = wmax[:,:,:] - cp[ :, 0, :,:] = vmin[:,:,:] - cp[ :,-1, :,:] = vmax[:,:,:] - cp[ 0, :, :,:] = umin[:,:,:] - cp[-1, :, :,:] = umax[:,:,:] - inner = np.linspace(-.5,.5, 3) - Y, X, Z = np.meshgrid(inner,inner,inner) - cp[1:4,1:4,1:4,0] = X - cp[1:4,1:4,1:4,1] = Y - cp[1:4,1:4,1:4,2] = Z - cp[1:4,1:4,1:4,3] = 1 - ball = Volume(b,b,b,cp,rational=True, raw=True) - return r*ball + center - else: - raise ValueError('invalid type argument') + cp = np.array( + [ + [-4 * (sr3 - 1), 4 * (1 - sr3), 4 * (1 - sr3), 4 * (3 - sr3)], # row 0 + [-sr2, sr2 * (sr3 - 4), sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [0, 4.0 / 3 * (1 - 2 * sr3), 4.0 / 3 * (1 - 2 * sr3), 4.0 / 3 * (5 - sr3)], + [sr2, sr2 * (sr3 - 4), sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [4 * (sr3 - 1), 4 * (1 - sr3), 4 * (1 - sr3), 4 * (3 - sr3)], + [-sr2 * (4 - sr3), -sr2, sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], # row 1 + [-(3 * sr3 - 2) / 2, (2 - 3 * sr3) / 2, -(sr3 + 6) / 2, (sr3 + 6) / 2], + [0, sr2 * (2 * sr3 - 7) / 3, -5 * sr6 / 3, sr2 * (sr3 + 6) / 3], + [(3 * sr3 - 2) / 2, (2 - 3 * sr3) / 2, -(sr3 + 6) / 2, (sr3 + 6) / 2], + [sr2 * (4 - sr3), -sr2, sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [-4.0 / 3 * (2 * sr3 - 1), 0, 4.0 / 3 * (1 - 2 * sr3), 4 * (5 - sr3) / 3], # row 2 + [-sr2 / 3 * (7 - 2 * sr3), 0, -5 * sr6 / 3, sr2 * (sr3 + 6) / 3], + [0, 0, 4 * (sr3 - 5) / 3, 4 * (5 * sr3 - 1) / 9], + [sr2 / 3 * (7 - 2 * sr3), 0, -5 * sr6 / 3, sr2 * (sr3 + 6) / 3], + [4.0 / 3 * (2 * sr3 - 1), 0, 4.0 / 3 * (1 - 2 * sr3), 4 * (5 - sr3) / 3], + [-sr2 * (4 - sr3), sr2, sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], # row 3 + [-(3 * sr3 - 2) / 2, -(2 - 3 * sr3) / 2, -(sr3 + 6) / 2, (sr3 + 6) / 2], + [0, -sr2 * (2 * sr3 - 7) / 3, -5 * sr6 / 3, sr2 * (sr3 + 6) / 3], + [(3 * sr3 - 2) / 2, -(2 - 3 * sr3) / 2, -(sr3 + 6) / 2, (sr3 + 6) / 2], + [sr2 * (4 - sr3), sr2, sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [-4 * (sr3 - 1), -4 * (1 - sr3), 4 * (1 - sr3), 4 * (3 - sr3)], # row 4 + [-sr2, -sr2 * (sr3 - 4), sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [0, -4.0 / 3 * (1 - 2 * sr3), 4.0 / 3 * (1 - 2 * sr3), 4.0 / 3 * (5 - sr3)], + [sr2, -sr2 * (sr3 - 4), sr2 * (sr3 - 4), sr2 * (3 * sr3 - 2)], + [4 * (sr3 - 1), -4 * (1 - sr3), 4 * (1 - sr3), 4 * (3 - sr3)], + ], + dtype=float, + ) + wmin = Surface(b, b, cp, rational=True) + wmax = wmin.clone().mirror([0, 0, 1]) + vmax = wmin.clone().rotate(pi / 2, [1, 0, 0]) + vmin = vmax.clone().mirror([0, 1, 0]) + umax = vmin.clone().rotate(pi / 2, [0, 0, 1]) + umin = umax.clone().mirror([1, 0, 0]) -def revolve(surf, theta=2 * pi, axis=(0,0,1)): - """ Revolve a volume by sweeping a surface in a rotational fashion around + # Ideally I would like to call edge_surfaces() now, but that function + # does not work with rational surfaces, so we'll just manually try + # and add some inner controlpoints + cp = np.zeros((5, 5, 5, 4)) + cp[:, :, 0, :] = wmin[:, :, :] + cp[:, :, -1, :] = wmax[:, :, :] + cp[:, 0, :, :] = vmin[:, :, :] + cp[:, -1, :, :] = vmax[:, :, :] + cp[0, :, :, :] = umin[:, :, :] + cp[-1, :, :, :] = umax[:, :, :] + inner = np.linspace(-0.5, 0.5, 3) + Y, X, Z = np.meshgrid(inner, inner, inner) + cp[1:4, 1:4, 1:4, 0] = X + cp[1:4, 1:4, 1:4, 1] = Y + cp[1:4, 1:4, 1:4, 2] = Z + cp[1:4, 1:4, 1:4, 3] = 1 + ball = Volume(b, b, b, cp, rational=True, raw=True) + return r * ball + center + + raise ValueError("invalid type argument") + + +def revolve( + surf: Surface, + theta: Scalar = 2 * pi, + axis: Scalars = (0, 0, 1), +) -> Volume: + """Revolve a volume by sweeping a surface in a rotational fashion around an axis. :param Surface surf: Surface to revolve @@ -119,9 +159,9 @@ def revolve(surf, theta=2 * pi, axis=(0,0,1)): # align axis with the z-axis normal_theta = atan2(axis[1], axis[0]) - normal_phi = atan2(sqrt(axis[0]**2 + axis[1]**2), axis[2]) - surf.rotate(-normal_theta, [0,0,1]) - surf.rotate(-normal_phi, [0,1,0]) + normal_phi = atan2(sqrt(axis[0] ** 2 + axis[1] ** 2), axis[2]) + surf.rotate(-normal_theta, [0, 0, 1]) + surf.rotate(-normal_phi, [0, 1, 0]) path = curve_factory.circle_segment(theta=theta) n = len(surf) # number of control points of the surface @@ -129,21 +169,29 @@ def revolve(surf, theta=2 * pi, axis=(0,0,1)): cp = np.zeros((m * n, 4)) - dt = np.sign(theta)*(path.knots(0)[1] - path.knots(0)[0]) / 2.0 + dt = np.sign(theta) * (path.knots(0)[1] - path.knots(0)[0]) / 2.0 for i in range(m): - weight = path[i,-1] - cp[i * n:(i + 1) * n, :] = np.reshape(surf.controlpoints.transpose(1, 0, 2), (n, 4)) - cp[i * n:(i + 1) * n, 2] *= weight - cp[i * n:(i + 1) * n, 3] *= weight + weight = path[i, -1] + cp[i * n : (i + 1) * n, :] = np.reshape(surf.controlpoints.transpose(1, 0, 2), (n, 4)) + cp[i * n : (i + 1) * n, 2] *= weight + cp[i * n : (i + 1) * n, 3] *= weight surf.rotate(dt) result = Volume(surf.bases[0], surf.bases[1], path.bases[0], cp, True) # rotate it back again - result.rotate(normal_phi, [0,1,0]) - result.rotate(normal_theta, [0,0,1]) + result.rotate(normal_phi, [0, 1, 0]) + result.rotate(normal_theta, [0, 0, 1]) return result -def torus(minor_r=1, major_r=3, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0), type='radial'): - """ Create a torus (doughnut) by revolving a circle of size *minor_r* + +def torus( + minor_r: Scalar = 1, + major_r: Scalar = 3, + center: Scalars = (0, 0, 0), + normal: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), + type: Literal["radial", "square"] = "radial", +) -> Volume: + """Create a torus (doughnut) by revolving a circle of size *minor_r* around the *z* axis with radius *major_r*. :param float minor_r: The thickness of the torus (radius in the *xz* plane) @@ -162,10 +210,18 @@ def torus(minor_r=1, major_r=3, center=(0,0,0), normal=(0,0,1), xaxis=(1,0,0), t result = revolve(disc) result.rotate(rotate_local_x_axis(xaxis, normal)) - return flip_and_move_plane_geometry(result, center, normal) + return result.flip_and_move_plane_geometry(center, normal) + -def cylinder(r=1, h=1, center=(0,0,0), axis=(0,0,1), xaxis=(1,0,0), type='radial'): - """ Create a solid cylinder +def cylinder( + r: Scalar = 1, + h: Scalar = 1, + center: Scalars = (0, 0, 0), + axis: Scalars = (0, 0, 1), + xaxis: Scalars = (1, 0, 0), + type: Literal["radial", "square"] = "radial", +) -> Volume: + """Create a solid cylinder :param float r: Radius :param float h: Height @@ -176,11 +232,11 @@ def cylinder(r=1, h=1, center=(0,0,0), axis=(0,0,1), xaxis=(1,0,0), type='radial :return: The cylinder :rtype: Volume """ - return extrude(surface_factory.disc(r, center, axis, xaxis=xaxis, type=type), h*np.array(axis)) + return extrude(surface_factory.disc(r, center, axis, xaxis=xaxis, type=type), h * np.array(axis)) -def extrude(surf, amount): - """ Extrude a surface by sweeping it to a given height. +def extrude(surf: Surface, amount: Scalars) -> Volume: + """Extrude a surface by sweeping it to a given height. :param Surface surf: Surface to extrude :param array-like amount: 3-component vector of sweeping amount and direction @@ -188,18 +244,28 @@ def extrude(surf, amount): :rtype: Volume """ surf.set_dimension(3) # add z-components (if not already present) - cp = [] - for controlpoint in surf: - cp.append(list(controlpoint)) + + ncomps = surf.dimension + surf.rational + cp = surf.controlpoints.copy().reshape(-1, ncomps, order="F") surf += amount - for controlpoint in surf: - cp.append(list(controlpoint)) + cp = np.append(cp, surf.controlpoints.reshape(-1, ncomps, order="F"), axis=0) surf -= amount - return Volume(surf.bases[0], surf.bases[1], BSplineBasis(2), cp, surf.rational) + + return Volume(surf.bases[0], surf.bases[1], BSplineBasis(2), cp, rational=surf.rational) -def edge_surfaces(*surfaces): - """ Create the volume defined by the region between the input surfaces. +@overload +def edge_surfaces(surfaces: Sequence[Surface]) -> Volume: + ... + + +@overload +def edge_surfaces(*surfaces: Surface) -> Volume: + ... + + +def edge_surfaces(*surfaces: Surface) -> Volume: # type: ignore[misc] + """Create the volume defined by the region between the input surfaces. In case of six input surfaces, these must be given in the order: bottom, top, left, right, back, front. Opposing sides must be parametrized in the @@ -210,8 +276,9 @@ def edge_surfaces(*surfaces): :rtype: Volume :raises ValueError: If the length of *surfaces* is not two or six """ - if len(surfaces) == 1: # probably gives input as a list-like single variable - surfaces = surfaces[0] + if len(surfaces) == 1: # probably gives input as a list-like single variable + surfaces = cast(tuple[Surface], surfaces[0]) + if len(surfaces) == 2: surf1 = surfaces[0].clone() surf2 = surfaces[1].clone() @@ -224,13 +291,13 @@ def edge_surfaces(*surfaces): # Volume constructor orders control points in a different way, so we # create it from scratch here - result = Volume(surf1.bases[0], surf1.bases[1], BSplineBasis(2), controlpoints, - rational=surf1.rational, raw=True) + return Volume( + surf1.bases[0], surf1.bases[1], BSplineBasis(2), controlpoints, rational=surf1.rational, raw=True + ) - return result - elif len(surfaces) == 6: - if any([surf.rational for surf in surfaces]): - raise RuntimeError('edge_surfaces not supported for rational splines') + if len(surfaces) == 6: + if any(surf.rational for surf in surfaces): + raise RuntimeError("edge_surfaces not supported for rational splines") # coons patch (https://en.wikipedia.org/wiki/Coons_patch) umin = surfaces[0] @@ -239,10 +306,10 @@ def edge_surfaces(*surfaces): vmax = surfaces[3] wmin = surfaces[4] wmax = surfaces[5] - vol1 = edge_surfaces(umin,umax) - vol2 = edge_surfaces(vmin,vmax) - vol3 = edge_surfaces(wmin,wmax) - vol4 = Volume(controlpoints=vol1.corners(order='F'), rational=vol1.rational) + vol1 = edge_surfaces(umin, umax) + vol2 = edge_surfaces(vmin, vmax) + vol3 = edge_surfaces(wmin, wmax) + vol4 = Volume(controlpoints=vol1.corners(order="F"), rational=vol1.rational) vol1.swap(0, 2) vol1.swap(1, 2) vol2.swap(1, 2) @@ -253,12 +320,11 @@ def edge_surfaces(*surfaces): Volume.make_splines_identical(vol2, vol3) Volume.make_splines_identical(vol2, vol4) Volume.make_splines_identical(vol3, vol4) - result = vol1.clone() - result.controlpoints += vol2.controlpoints - result.controlpoints += vol3.controlpoints - + result = vol1.clone() + result.controlpoints += vol2.controlpoints + result.controlpoints += vol3.controlpoints - ### as suggested by github user UnaiSan (see issues 141) + ### as suggested by github user UnaiSan (see issues 141) result.controlpoints += vol4.controlpoints Nu, Nv, Nw, d = result.controlpoints.shape @@ -274,7 +340,7 @@ def edge_surfaces(*surfaces): result.bases[2], controlpoints=controlpoints, raw=True, - rational=result.rational + rational=result.rational, ) controlpoints = np.zeros((Nu, 2, 2, d)) @@ -288,7 +354,7 @@ def edge_surfaces(*surfaces): BSplineBasis(), controlpoints=controlpoints, raw=True, - rational=result.rational + rational=result.rational, ) controlpoints = np.zeros((2, Nv, 2, d)) @@ -302,7 +368,7 @@ def edge_surfaces(*surfaces): BSplineBasis(), controlpoints=controlpoints, raw=True, - rational=result.rational + rational=result.rational, ) Volume.make_splines_identical(result, vol_u_edges) @@ -314,11 +380,12 @@ def edge_surfaces(*surfaces): result.controlpoints -= vol_w_edges.controlpoints return result - else: - raise ValueError('Requires two or six input surfaces') -def sweep(path, shape): - """ Generate a surface by sweeping a shape along a path + raise ValueError("Requires two or six input surfaces") + + +def sweep(path: Curve, shape: Surface) -> Volume: + """Generate a surface by sweeping a shape along a path The resulting surface is an approximation generated by interpolating at the Greville points. It is generated by sweeping a shape curve along a path. @@ -339,12 +406,12 @@ def sweep(path, shape): n2 = b2.num_functions() n3 = b3.num_functions() # this requires binormals and normals, which only work in 3D, so assume this here - X = np.zeros((n1,n2,n3, 3)) + X = np.zeros((n1, n2, n3, 3)) # pre-evaluate the surface u = b1.greville() v = b2.greville() - y = shape(u,v) + y = shape(u, v) for k in range(n3): w = b3.greville(k) @@ -353,13 +420,23 @@ def sweep(path, shape): N = path.normal(w) for i in range(n1): for j in range(n2): - X[i,j,k,:] = x + N*y[i,j,0] + B*y[i,j,1] + X[i, j, k, :] = x + N * y[i, j, 0] + B * y[i, j, 1] + + return interpolate(X, [b1, b2, b3]) + + +@overload +def loft(surfaces: Sequence[Surface]) -> Volume: + ... + - return interpolate(X, [b1,b2,b3]) +@overload +def loft(*surfaces: Surface) -> Volume: + ... -def loft(*surfaces): - """ Generate a volume by lofting a series of surfaces +def loft(*surfaces: Surface) -> Volume: # type: ignore[misc] + """Generate a volume by lofting a series of surfaces The resulting volume is interpolated at all input surfaces and a smooth transition between these surfaces is computed as a cubic spline interpolation in the lofting @@ -392,70 +469,72 @@ def loft(*surfaces): """ if len(surfaces) == 1: - surfaces = surfaces[0] + surfaces = cast(tuple[Surface], surfaces[0]) # clone input, so we don't change those references # make sure everything has the same dimension since we need to compute length - surfaces = [s.clone().set_dimension(3) for s in surfaces] - if len(surfaces)==2: - return surface_factory.edge_curves(surfaces) - elif len(surfaces)==3: + surfaces = tuple(s.clone().set_dimension(3) for s in surfaces) + + if len(surfaces) == 2: + return edge_surfaces(surfaces) + + if len(surfaces) == 3: # can't do cubic spline interpolation, so we'll do quadratic basis3 = BSplineBasis(3) - dist = basis3.greville() + dist = basis3.greville() + else: x = [s.center() for s in surfaces] # create knot vector from the euclidian length between the surfaces - dist = [0] - for (x1,x0) in zip(x[1:],x[:-1]): - dist.append(dist[-1] + np.linalg.norm(x1-x0)) + dist = np.zeros((len(x),), dtype=float) + curve_length_parametrization(x, buffer=dist[1:]) # using "free" boundary condition by setting N'''(u) continuous at second to last and second knot - knot = [dist[0]]*4 + dist[2:-2] + [dist[-1]]*4 + knot = np.fromiter(chain(repeat(dist[0], 4), dist[2:-2], repeat(dist[-1], 4)), dtype=float) basis3 = BSplineBasis(4, knot) n = len(surfaces) for i in range(n): - for j in range(i+1,n): + for j in range(i + 1, n): Surface.make_splines_identical(surfaces[i], surfaces[j]) basis1 = surfaces[0].bases[0] basis2 = surfaces[0].bases[1] - m1 = basis1.num_functions() - m2 = basis2.num_functions() - dim = len(surfaces[0][0]) - u = basis1.greville() # parametric interpolation points - v = basis2.greville() - w = dist + m1 = basis1.num_functions() + m2 = basis2.num_functions() + dim = len(surfaces[0][0]) + u = basis1.greville() # parametric interpolation points + v = basis2.greville() + w = dist # compute matrices - Nu = basis1(u) - Nv = basis2(v) - Nw = basis3(w) + Nu = basis1(u) + Nv = basis2(v) + Nw = basis3(w) Nu_inv = np.linalg.inv(Nu) Nv_inv = np.linalg.inv(Nv) Nw_inv = np.linalg.inv(Nw) # compute interpolation points in physical space - x = np.zeros((m1,m2,n, dim)) + xx = np.zeros((m1, m2, n, dim), dtype=float) for i in range(n): - tmp = np.tensordot(Nv, surfaces[i].controlpoints, axes=(1,1)) - x[:,:,i,:] = np.tensordot(Nu, tmp , axes=(1,1)) + tmp = np.tensordot(Nv, surfaces[i].controlpoints, axes=(1, 1)) + xx[:, :, i, :] = np.tensordot(Nu, tmp, axes=(1, 1)) # solve interpolation problem - cp = np.tensordot(Nw_inv, x, axes=(1,2)) - cp = np.tensordot(Nv_inv, cp, axes=(1,2)) - cp = np.tensordot(Nu_inv, cp, axes=(1,2)) + cp = np.tensordot(Nw_inv, xx, axes=(1, 2)) + cp = np.tensordot(Nv_inv, cp, axes=(1, 2)) + cp = np.tensordot(Nu_inv, cp, axes=(1, 2)) # re-order controlpoints so they match up with Surface constructor - cp = np.reshape(cp.transpose((2, 1, 0, 3)), (m1*m2*n, dim)) + cp = np.reshape(cp.transpose((2, 1, 0, 3)), (m1 * m2 * n, dim)) - return Volume(basis1, basis2, basis3, cp, surfaces[0].rational) + return Volume(basis1, basis2, basis3, cp, rational=surfaces[0].rational) -def interpolate(x, bases, u=None): - """ Interpolate a volume on a set of regular gridded interpolation points `x`. +def interpolate(x: FArray, bases: Sequence[BSplineBasis], u: Optional[Sequence[Scalars]] = None) -> Volume: + """Interpolate a volume on a set of regular gridded interpolation points `x`. The points can be either a matrix (in which case the first index is interpreted as a flat row-first index of the interpolation grid) or a 4D @@ -474,17 +553,17 @@ def interpolate(x, bases, u=None): x = x.reshape(vol_shape + [dim]) if u is None: u = [b.greville() for b in bases] - N_all = [b(t) for b,t in zip(bases, u)] + N_all = [b(t) for b, t in zip(bases, u)] N_all.reverse() cp = x for N in N_all: - cp = np.tensordot(np.linalg.inv(N), cp, axes=(1,2)) + cp = np.tensordot(np.linalg.inv(N), cp, axes=(1, 2)) - return Volume(bases[0], bases[1], bases[2], cp.transpose(2,1,0,3).reshape((np.prod(vol_shape),dim))) + return Volume(bases[0], bases[1], bases[2], cp.transpose(2, 1, 0, 3).reshape((np.prod(vol_shape), dim))) -def least_square_fit(x, bases, u): - """ Perform a least-square fit of a point cloud `x` onto a spline basis. +def least_square_fit(x: FArray, bases: Sequence[BSplineBasis], u: Sequence[Scalars]) -> Volume: + """Perform a least-square fit of a point cloud `x` onto a spline basis. The points can be either a matrix (in which case the first index is interpreted as a flat row-first index of the interpolation grid) or a 4D @@ -502,12 +581,12 @@ def least_square_fit(x, bases, u): dim = x.shape[-1] if len(x.shape) == 2: x = x.reshape(vol_shape + [dim]) - N_all = [b(t) for b,t in zip(bases, u)] + N_all = [b(t) for b, t in zip(bases, u)] N_all.reverse() cp = x for N in N_all: - cp = np.tensordot(N.T, cp, axes=(1,2)) + cp = np.tensordot(N.T, cp, axes=(1, 2)) for N in N_all: - cp = np.tensordot(np.linalg.inv(N.T @ N), cp, axes=(1,2)) + cp = np.tensordot(np.linalg.inv(N.T @ N), cp, axes=(1, 2)) - return Volume(bases[0], bases[1], bases[2], cp.transpose(2,1,0,3).reshape((np.prod(vol_shape),dim))) + return Volume(bases[0], bases[1], bases[2], cp.transpose(2, 1, 0, 3).reshape((np.prod(vol_shape), dim))) diff --git a/stubs/nutils/__init__.pyi b/stubs/nutils/__init__.pyi new file mode 100644 index 0000000..0acf43f --- /dev/null +++ b/stubs/nutils/__init__.pyi @@ -0,0 +1 @@ +version: tuple[int, ...] diff --git a/stubs/nutils/function.pyi b/stubs/nutils/function.pyi new file mode 100644 index 0000000..a62d306 --- /dev/null +++ b/stubs/nutils/function.pyi @@ -0,0 +1,51 @@ +from __future__ import annotations + +from typing import Any, Union + +from numpy.typing import NDArray +from numpy import float_ + + +class Function: + def grad(self, other: Function) -> Function: + ... + + def sum(self, axis: int) -> Function: + ... + + def vector(self, n: int) -> Function: + ... + + def __mul__(self, other: Any) -> Function: + ... + + +class Namespace: + def __setattr__( + self, + name: str, + value: Union[ + Function, + int, + float, + NDArray[float_], + str, + ] + ) -> None: + ... + + def eval_nm(self, code: str) -> Function: + ... + + def eval_n(self, code: str) -> Function: + ... + + +def outer(x: Function, y: Function) -> Function: + ... + +def J(x: Function) -> Function: + ... + +def matmat(x: Function, y: NDArray[float_]) -> Function: + ... diff --git a/stubs/nutils/mesh.pyi b/stubs/nutils/mesh.pyi new file mode 100644 index 0000000..c22229b --- /dev/null +++ b/stubs/nutils/mesh.pyi @@ -0,0 +1,45 @@ +from typing import Sequence, Literal +from typing_extensions import Self + +from numpy.typing import NDArray +from numpy import float_ + +from .function import Function +from .sample import Integral + + +class Matrix: + def solve( + self, + rhs: NDArray[float_], + constrain: NDArray[float_], + ) -> NDArray[float_]: + ... + + +class Domain: + def basis( + self, + name: Literal["spline"], + degree: Sequence[int], + knotmultiplicities: Sequence[Sequence[int]] + ) -> Function: + ... + + def integrate( + self, + x: Function, + ischeme: str, + ) -> Matrix: + ... + + def integral( + self, + x: Function, + degree: int, + ) -> Integral: + ... + + +def rectilinear(args: Sequence[NDArray[float_]]) -> tuple[Domain, Function]: + ... diff --git a/stubs/nutils/sample.pyi b/stubs/nutils/sample.pyi new file mode 100644 index 0000000..cda3d07 --- /dev/null +++ b/stubs/nutils/sample.pyi @@ -0,0 +1,2 @@ +class Integral: + ... diff --git a/stubs/nutils/solver.pyi b/stubs/nutils/solver.pyi new file mode 100644 index 0000000..c8d32ef --- /dev/null +++ b/stubs/nutils/solver.pyi @@ -0,0 +1,18 @@ +from numpy.typing import NDArray +from numpy import float_ + +from .function import Function +from .sample import Integral + + +class newton: + def __init__( + self, + x: str, + residual: Integral, + constrain: NDArray[float_], + ) -> None: + ... + + def solve(self, tol: float, maxiter: int) -> NDArray[float_]: + ... diff --git a/stubs/scipy/ndimage.pyi b/stubs/scipy/ndimage.pyi new file mode 100644 index 0000000..4a0147b --- /dev/null +++ b/stubs/scipy/ndimage.pyi @@ -0,0 +1,6 @@ +from numpy.typing import NDArray +import numpy as np + + +def convolve(input: NDArray[np.float_], weights: NDArray[np.float_], mode: str) -> NDArray[np.float_]: + ... diff --git a/stubs/scipy/sparse/__init__.pyi b/stubs/scipy/sparse/__init__.pyi new file mode 100644 index 0000000..9aa43b9 --- /dev/null +++ b/stubs/scipy/sparse/__init__.pyi @@ -0,0 +1,35 @@ +from typing import overload, TypeVar, Sequence + +from numpy.typing import NDArray +from numpy import float_, int_, generic + + +T = TypeVar("T", covariant=True, bound=generic) + + +class csr_matrix: + @overload + def __init__(self, shape: tuple[int, int]) -> None: + ... + + @overload + def __init__( + self, + data: tuple[NDArray[float_], NDArray[int_], NDArray[int_]], + shape: tuple[int, int], + ) -> None: + ... + + @overload + def __init__(self, array: NDArray[float_]) -> None: + ... + + def toarray(self) -> NDArray[float_]: + ... + + def __matmul__(self, other: NDArray[T]) -> NDArray[T]: + ... + + +def vstack(blocks: Sequence[csr_matrix]) -> csr_matrix: + ... diff --git a/stubs/scipy/sparse/linalg.pyi b/stubs/scipy/sparse/linalg.pyi new file mode 100644 index 0000000..9e4f021 --- /dev/null +++ b/stubs/scipy/sparse/linalg.pyi @@ -0,0 +1,12 @@ +from typing import TypeVar + +from numpy.typing import NDArray +from numpy import floating + +from . import csr_matrix + + +T = TypeVar("T", covariant=True, bound=floating) + +def spsolve(mx: csr_matrix, rhs: NDArray[T]) -> NDArray[T]: + ... diff --git a/stubs/scipy/spatial.pyi b/stubs/scipy/spatial.pyi new file mode 100644 index 0000000..d3a177a --- /dev/null +++ b/stubs/scipy/spatial.pyi @@ -0,0 +1,9 @@ +from numpy.typing import NDArray +import numpy as np + + +class ConvexHull: + vertices: NDArray[np.int_] + + def __init__(self, points: NDArray[np.float_]) -> None: + ... diff --git a/test/basis_test.py b/test/basis_test.py index a403020..eb930f0 100644 --- a/test/basis_test.py +++ b/test/basis_test.py @@ -66,7 +66,7 @@ def test_greville(self): self.assertAlmostEqual(b.greville(0), 0.0) self.assertAlmostEqual(b.greville(1), 1.0 / 3.0) self.assertAlmostEqual(b.greville(2), 1.0) - self.assertAlmostEqual(b.greville(), [0.0, 1.0/3.0, 1.0, 2.0, 8.0/3.0, 3.0]) + np.testing.assert_allclose(b.greville(), [0.0, 1.0/3.0, 1.0, 2.0, 8.0/3.0, 3.0]) def test_raise_order(self): # test normal knot vector diff --git a/test/curve_test.py b/test/curve_test.py index 85fd3cf..3d89fd9 100644 --- a/test/curve_test.py +++ b/test/curve_test.py @@ -405,7 +405,7 @@ def test_tangent_and_normal(self): crv.set_dimension(3) t = np.linspace(crv.start(0), crv.end(0), 13) X = crv(t) - T = crv.tangent(t) + T, = crv.tangent(t) B = crv.binormal(t) N = crv.normal(t) @@ -427,7 +427,7 @@ def test_tangent_and_normal(self): self.assertTrue(np.allclose( b, [0,0,1]) ) # check that evaluations work for single-valued input - t = crv.tangent(.23) + t, = crv.tangent(.23) b = crv.binormal(.23) n = crv.normal(.23) self.assertEqual(len(t.shape), 1) # is a vector (not matrix) diff --git a/test/io/g2_test.py b/test/io/g2_test.py index 9be7290..afcc6a9 100644 --- a/test/io/g2_test.py +++ b/test/io/g2_test.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- +from pathlib import Path + from splipy.io import G2 import splipy.surface_factory as SurfaceFactory import splipy.volume_factory as VolumeFactory @@ -7,12 +9,12 @@ import unittest import os -THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +THIS_DIR = Path(__file__).parent class TestG2(unittest.TestCase): def test_read_rational_surf(self): - with G2(THIS_DIR + '/geometries/torus.g2') as myfile: + with G2(THIS_DIR / 'geometries' / 'torus.g2') as myfile: surf = myfile.read() self.assertEqual(len(surf), 1) surf = surf[0] @@ -22,7 +24,7 @@ def test_read_rational_surf(self): def test_write_and_read_multipatch_surface(self): # write teapot to file and test if its there teapot = SurfaceFactory.teapot() - with G2('teapot.g2') as myfile: + with G2('teapot.g2', 'w') as myfile: myfile.write(teapot) self.assertTrue(os.path.isfile('teapot.g2')) @@ -37,7 +39,7 @@ def test_write_and_read_multipatch_surface(self): os.remove('teapot.g2') def test_read_doublespaced(self): - with G2(THIS_DIR + '/geometries/lshape.g2') as myfile: # controlpoints are separated by two spaces + with G2(THIS_DIR / 'geometries' / 'lshape.g2') as myfile: # controlpoints are separated by two spaces one_surf = myfile.read() self.assertEqual(len(one_surf), 1) self.assertEqual(one_surf[0].shape[0], 3) @@ -46,7 +48,7 @@ def test_read_doublespaced(self): def test_write_and_read_surface(self): # write disc to file and test if its there disc = SurfaceFactory.disc(type='square') - with G2('disc.g2') as myfile: + with G2('disc.g2', 'w') as myfile: myfile.write(disc) self.assertTrue(os.path.isfile('disc.g2')) @@ -64,7 +66,7 @@ def test_write_and_read_surface(self): def test_write_and_read_volume(self): # write sphere to file and test if its there sphere = VolumeFactory.sphere(type='square') - with G2('sphere.g2') as myfile: + with G2('sphere.g2', 'w') as myfile: myfile.write(sphere) self.assertTrue(os.path.isfile('sphere.g2')) @@ -80,7 +82,7 @@ def test_write_and_read_volume(self): os.remove('sphere.g2') def test_read_elementary_curves(self): - with G2(THIS_DIR + '/geometries/elementary_curves.g2') as myfile: + with G2(THIS_DIR / 'geometries' / 'elementary_curves.g2') as myfile: my_curves = myfile.read() self.assertEqual(len(my_curves), 3) @@ -106,7 +108,7 @@ def test_read_elementary_curves(self): self.assertTrue(np.allclose(line[0], [1,0,0])) def test_read_elementary_surfaces(self): - with G2(THIS_DIR + '/geometries/elementary_surfaces.g2') as myfile: + with G2(THIS_DIR / 'geometries' / 'elementary_surfaces.g2') as myfile: my_surfaces = myfile.read() self.assertEqual(len(my_surfaces), 4) @@ -134,7 +136,7 @@ def test_read_elementary_surfaces(self): def test_from_step(self): # quite large nasty g2 file which contains cylinders, planes, trimming etc - with G2(THIS_DIR + '/geometries/winglet_from_step.g2') as myfile: + with G2(THIS_DIR / 'geometries' / 'winglet_from_step.g2') as myfile: my_surfaces = myfile.read() trim_curves = myfile.trimming_curves diff --git a/test/surface_test.py b/test/surface_test.py index eb43f6b..ab4529a 100644 --- a/test/surface_test.py +++ b/test/surface_test.py @@ -545,7 +545,7 @@ def test_center(self): # make an ellipse at (2,1) surf = sf.disc(3) print(surf) - surf.scale((3,1)) + surf.scale(3,1) surf += (2,1) center = surf.center() self.assertEqual(len(center), 2)