diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 6edf5eb03b..3f540e3557 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -91,6 +91,7 @@ jobs: library-name: ${{ env.PACKAGE_NAME }} operating-system: ${{ matrix.os }} python-version: ${{ matrix.python-version }} + whitelist-license-check: 'attrs' docker-style: name: Docker Style Check @@ -160,10 +161,7 @@ jobs: testing-windows: name: Testing and coverage (Windows) needs: [smoke-tests, manifests] - # runs-on: [self-hosted, Windows, pygeometry] - runs-on: # TODO: Waiting for ansys-network runner to be updated - group: pyansys-self-hosted - labels: [self-hosted, Windows, pygeometry] + runs-on: [self-hosted, Windows, pygeometry] continue-on-error: ${{ matrix.experimental }} env: SKIP_UNSTABLE: false @@ -336,10 +334,7 @@ jobs: docs: name: Documentation needs: [docs-style] - # Doc build performed on self-hosted runners outside the Ansys network only - runs-on: - group: pyansys-self-hosted - labels: [Windows, pygeometry] + runs-on: [self-hosted, Windows, pygeometry] env: PYVISTA_OFF_SCREEN: true steps: @@ -649,18 +644,17 @@ jobs: build-windows-container: name: Building Geometry Service - Windows - # runs-on: [self-hosted, Windows, pygeometry] - runs-on: # TODO: Waiting for ansys-network runner to be updated - group: pyansys-self-hosted - labels: [self-hosted, Windows, pygeometry] + runs-on: [self-hosted, Windows, pygeometry] needs: [fetch-release-artifacts] strategy: fail-fast: false matrix: include: - mode: "dms" + docker-file: "windows-dms-dockerfile.zip" zip-file: "windows-dms-binaries.zip" - mode: "coreservice" + docker-file: "windows-core-dockerfile.zip" zip-file: "windows-core-binaries.zip" steps: - name: Checkout repository @@ -715,13 +709,13 @@ jobs: uses: vimtor/action-zip@v1.2 with: files: docker/windows/${{ matrix.mode }}/Dockerfile - dest: windows-${{ matrix.mode }}-dockerfile.zip + dest: ${{ matrix.docker-file }} - name: Upload Windows Dockerfile uses: actions/upload-artifact@v4 with: - name: windows-${{ matrix.mode }}-dockerfile.zip - path: windows-${{ matrix.mode }}-dockerfile.zip + name: ${{ matrix.docker-file }} + path: ${{ matrix.docker-file }} retention-days: 7 - name: Stop the Geometry service @@ -772,7 +766,7 @@ jobs: - name: Build Docker image working-directory: docker run: | - docker build -f linux/Dockerfile -t ghcr.io/ansys/geometry:linux-tmp . + docker build -f linux/coreservice/Dockerfile -t ghcr.io/ansys/geometry:linux-tmp . - name: Launch Geometry service run: | diff --git a/.github/workflows/nightly_docker_test.yml b/.github/workflows/nightly_docker_test.yml index af3131626c..6230cbaf5f 100644 --- a/.github/workflows/nightly_docker_test.yml +++ b/.github/workflows/nightly_docker_test.yml @@ -200,10 +200,7 @@ jobs: name: Windows Core Service needs: manifests if: needs.manifests.outputs.skip_core_windows == 0 - # runs-on: [self-hosted, Windows, pygeometry] - runs-on: # TODO: Waiting for ansys-network runner to be updated - group: pyansys-self-hosted - labels: [self-hosted, Windows, pygeometry] + runs-on: [self-hosted, Windows, pygeometry] env: PYVISTA_OFF_SCREEN: true steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca8eff6f0c..2856dc3cef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,13 +7,13 @@ exclude: "tests/integration/files" repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.9.3 hooks: - id: ruff - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.0 hooks: - id: codespell args: ["--ignore-words", "doc/styles/config/vocabularies/ANSYS/accept.txt", "-w"] @@ -27,7 +27,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/ansys/pre-commit-hooks - rev: v0.4.4 + rev: v0.5.1 hooks: - id: add-license-headers args: @@ -35,7 +35,7 @@ repos: # this validates our github workflow files - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.30.0 + rev: 0.31.0 hooks: - id: check-github-workflows diff --git a/doc/changelog.d/1339.changed.md b/doc/changelog.d/1339.changed.md deleted file mode 100644 index 6a1fe5e788..0000000000 --- a/doc/changelog.d/1339.changed.md +++ /dev/null @@ -1 +0,0 @@ -chore: update CHANGELOG for v0.6.6 \ No newline at end of file diff --git a/doc/changelog.d/1366.maintenance.md b/doc/changelog.d/1366.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1366.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1481.added.md b/doc/changelog.d/1481.added.md deleted file mode 100644 index fe048c7ac1..0000000000 --- a/doc/changelog.d/1481.added.md +++ /dev/null @@ -1 +0,0 @@ -active support for Python 3.13 \ No newline at end of file diff --git a/doc/changelog.d/1545.maintenance.md b/doc/changelog.d/1545.maintenance.md deleted file mode 100644 index f4da961b47..0000000000 --- a/doc/changelog.d/1545.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -update CHANGELOG for v0.7.6 \ No newline at end of file diff --git a/doc/changelog.d/1546.maintenance.md b/doc/changelog.d/1546.maintenance.md deleted file mode 100644 index 4403b5fa10..0000000000 --- a/doc/changelog.d/1546.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -change release artifacts self-hosted runner \ No newline at end of file diff --git a/doc/changelog.d/1547.dependencies.md b/doc/changelog.d/1547.dependencies.md deleted file mode 100644 index 844381a3e2..0000000000 --- a/doc/changelog.d/1547.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.16 to 0.4.17 \ No newline at end of file diff --git a/doc/changelog.d/1548.added.md b/doc/changelog.d/1548.added.md deleted file mode 100644 index 8d93f06047..0000000000 --- a/doc/changelog.d/1548.added.md +++ /dev/null @@ -1 +0,0 @@ -allow version input to automatically consider the nuances for the Ansys Student version \ No newline at end of file diff --git a/doc/changelog.d/1549.dependencies.md b/doc/changelog.d/1549.dependencies.md deleted file mode 100644 index 14e8e914b2..0000000000 --- a/doc/changelog.d/1549.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-sphinx-theme[autoapi] from 1.2.1 to 1.2.2 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1550.dependencies.md b/doc/changelog.d/1550.dependencies.md deleted file mode 100644 index 60ca25e301..0000000000 --- a/doc/changelog.d/1550.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.17 to 0.4.18 \ No newline at end of file diff --git a/doc/changelog.d/1552.maintenance.md b/doc/changelog.d/1552.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1552.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1553.maintenance.md b/doc/changelog.d/1553.maintenance.md deleted file mode 100644 index 7420ce8734..0000000000 --- a/doc/changelog.d/1553.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -automerge pre-commit.ci PRs \ No newline at end of file diff --git a/doc/changelog.d/1554.dependencies.md b/doc/changelog.d/1554.dependencies.md deleted file mode 100644 index 6591744b26..0000000000 --- a/doc/changelog.d/1554.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-tools-visualization-interface from 0.5.0 to 0.6.0 \ No newline at end of file diff --git a/doc/changelog.d/1555.maintenance.md b/doc/changelog.d/1555.maintenance.md deleted file mode 100644 index 6c906737f7..0000000000 --- a/doc/changelog.d/1555.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -bump pyvista/setup-headless-display-action to v3 \ No newline at end of file diff --git a/doc/changelog.d/1556.fixed.md b/doc/changelog.d/1556.fixed.md deleted file mode 100644 index 62d777bd70..0000000000 --- a/doc/changelog.d/1556.fixed.md +++ /dev/null @@ -1 +0,0 @@ -numpydoc warnings \ No newline at end of file diff --git a/doc/changelog.d/1559.added.md b/doc/changelog.d/1559.added.md deleted file mode 100644 index f5cc1c9749..0000000000 --- a/doc/changelog.d/1559.added.md +++ /dev/null @@ -1 +0,0 @@ -adapt health check timeout algorithm \ No newline at end of file diff --git a/doc/changelog.d/1561.maintenance.md b/doc/changelog.d/1561.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1561.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1562.dependencies.md b/doc/changelog.d/1562.dependencies.md deleted file mode 100644 index 2a4331b965..0000000000 --- a/doc/changelog.d/1562.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump pytest from 8.3.3 to 8.3.4 \ No newline at end of file diff --git a/doc/changelog.d/1568.dependencies.md b/doc/changelog.d/1568.dependencies.md deleted file mode 100644 index c95314ae7e..0000000000 --- a/doc/changelog.d/1568.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump six from 1.16.0 to 1.17.0 \ No newline at end of file diff --git a/doc/changelog.d/1570.dependencies.md b/doc/changelog.d/1570.dependencies.md deleted file mode 100644 index 4071eba4f5..0000000000 --- a/doc/changelog.d/1570.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump the docs-deps group across 1 directory with 2 updates \ No newline at end of file diff --git a/doc/changelog.d/1571.added.md b/doc/changelog.d/1571.added.md deleted file mode 100644 index 949341f8c3..0000000000 --- a/doc/changelog.d/1571.added.md +++ /dev/null @@ -1 +0,0 @@ -add core service support \ No newline at end of file diff --git a/doc/changelog.d/1574.dependencies.md b/doc/changelog.d/1574.dependencies.md deleted file mode 100644 index cd7a71dfc6..0000000000 --- a/doc/changelog.d/1574.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.18 to 0.4.20 \ No newline at end of file diff --git a/doc/changelog.d/1575.dependencies.md b/doc/changelog.d/1575.dependencies.md deleted file mode 100644 index 1cdb4f9a4f..0000000000 --- a/doc/changelog.d/1575.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump numpy from 2.1.3 to 2.2.0 \ No newline at end of file diff --git a/doc/changelog.d/1581.dependencies.md b/doc/changelog.d/1581.dependencies.md deleted file mode 100644 index 56b9f21194..0000000000 --- a/doc/changelog.d/1581.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.20 to 0.4.23 \ No newline at end of file diff --git a/doc/changelog.d/1582.dependencies.md b/doc/changelog.d/1582.dependencies.md deleted file mode 100644 index 81597907a8..0000000000 --- a/doc/changelog.d/1582.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.23 to 0.4.24 \ No newline at end of file diff --git a/doc/changelog.d/1583.dependencies.md b/doc/changelog.d/1583.dependencies.md deleted file mode 100644 index d4324bfdf1..0000000000 --- a/doc/changelog.d/1583.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-tools-visualization-interface from 0.6.0 to 0.6.1 \ No newline at end of file diff --git a/doc/changelog.d/1584.fixed.md b/doc/changelog.d/1584.fixed.md deleted file mode 100644 index ee772e6165..0000000000 --- a/doc/changelog.d/1584.fixed.md +++ /dev/null @@ -1 +0,0 @@ -vtk/pyvista issues \ No newline at end of file diff --git a/doc/changelog.d/1586.dependencies.md b/doc/changelog.d/1586.dependencies.md deleted file mode 100644 index 87a0275fa0..0000000000 --- a/doc/changelog.d/1586.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-tools-visualization-interface from 0.6.1 to 0.6.2 \ No newline at end of file diff --git a/doc/changelog.d/1587.added.md b/doc/changelog.d/1587.added.md deleted file mode 100644 index dbb5c7b5fa..0000000000 --- a/doc/changelog.d/1587.added.md +++ /dev/null @@ -1 +0,0 @@ -create launcher for core services \ No newline at end of file diff --git a/doc/changelog.d/1588.maintenance.md b/doc/changelog.d/1588.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1588.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1589.dependencies.md b/doc/changelog.d/1589.dependencies.md deleted file mode 100644 index b9c5b2fa18..0000000000 --- a/doc/changelog.d/1589.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -avoid the usage of attrs 24.3.0 (temporary) \ No newline at end of file diff --git a/doc/changelog.d/1590.dependencies.md b/doc/changelog.d/1590.dependencies.md deleted file mode 100644 index 9b6f267828..0000000000 --- a/doc/changelog.d/1590.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump jupytext from 1.16.4 to 1.16.5 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1591.maintenance.md b/doc/changelog.d/1591.maintenance.md deleted file mode 100644 index cb94f1814c..0000000000 --- a/doc/changelog.d/1591.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -decouple unstable image promotion \ No newline at end of file diff --git a/doc/changelog.d/1592.maintenance.md b/doc/changelog.d/1592.maintenance.md deleted file mode 100644 index ef7b20ec82..0000000000 --- a/doc/changelog.d/1592.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -skip unnecessary stages when containers are the same \ No newline at end of file diff --git a/doc/changelog.d/1593.dependencies.md b/doc/changelog.d/1593.dependencies.md deleted file mode 100644 index 7de09eef44..0000000000 --- a/doc/changelog.d/1593.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump jupytext from 1.16.5 to 1.16.6 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1595.dependencies.md b/doc/changelog.d/1595.dependencies.md deleted file mode 100644 index b405997db6..0000000000 --- a/doc/changelog.d/1595.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump panel from 1.5.4 to 1.5.5 \ No newline at end of file diff --git a/doc/changelog.d/1597.dependencies.md b/doc/changelog.d/1597.dependencies.md deleted file mode 100644 index edfa8df19b..0000000000 --- a/doc/changelog.d/1597.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-sphinx-theme[autoapi] from 1.2.3 to 1.2.4 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1598.dependencies.md b/doc/changelog.d/1598.dependencies.md deleted file mode 100644 index d48de4b15f..0000000000 --- a/doc/changelog.d/1598.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump notebook from 7.3.1 to 7.3.2 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1599.dependencies.md b/doc/changelog.d/1599.dependencies.md deleted file mode 100644 index aac66ad5af..0000000000 --- a/doc/changelog.d/1599.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump numpy from 2.2.0 to 2.2.1 \ No newline at end of file diff --git a/doc/changelog.d/1600.dependencies.md b/doc/changelog.d/1600.dependencies.md deleted file mode 100644 index 2056c9ff35..0000000000 --- a/doc/changelog.d/1600.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-tools-path from 0.7.0 to 0.7.1 \ No newline at end of file diff --git a/doc/changelog.d/1601.maintenance.md b/doc/changelog.d/1601.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1601.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1602.dependencies.md b/doc/changelog.d/1602.dependencies.md deleted file mode 100644 index d594fec0fe..0000000000 --- a/doc/changelog.d/1602.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump nbsphinx from 0.9.5 to 0.9.6 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1603.fixed.md b/doc/changelog.d/1603.fixed.md deleted file mode 100644 index 11a2effd18..0000000000 --- a/doc/changelog.d/1603.fixed.md +++ /dev/null @@ -1 +0,0 @@ -make_child_logger only takes 2 args. \ No newline at end of file diff --git a/doc/changelog.d/1604.maintenance.md b/doc/changelog.d/1604.maintenance.md deleted file mode 100644 index 6feed091ce..0000000000 --- a/doc/changelog.d/1604.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -Numpy is already imported at the top of the module. \ No newline at end of file diff --git a/doc/changelog.d/1605.documentation.md b/doc/changelog.d/1605.documentation.md deleted file mode 100644 index ad79e34a3c..0000000000 --- a/doc/changelog.d/1605.documentation.md +++ /dev/null @@ -1 +0,0 @@ -Explain how to report a security issue. \ No newline at end of file diff --git a/doc/changelog.d/1608.maintenance.md b/doc/changelog.d/1608.maintenance.md deleted file mode 100644 index 10d50c6699..0000000000 --- a/doc/changelog.d/1608.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -update license year using pre-commit hook \ No newline at end of file diff --git a/doc/changelog.d/1609.dependencies.md b/doc/changelog.d/1609.dependencies.md deleted file mode 100644 index be70dbf68c..0000000000 --- a/doc/changelog.d/1609.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump nbconvert from 7.16.4 to 7.16.5 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1610.dependencies.md b/doc/changelog.d/1610.dependencies.md deleted file mode 100644 index ec49bf5c03..0000000000 --- a/doc/changelog.d/1610.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump ansys-api-geometry from 0.4.24 to 0.4.25 \ No newline at end of file diff --git a/doc/changelog.d/1611.dependencies.md b/doc/changelog.d/1611.dependencies.md deleted file mode 100644 index 299cd3f5ba..0000000000 --- a/doc/changelog.d/1611.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump sphinx-autodoc-typehints from 2.5.0 to 3.0.0 in the docs-deps group \ No newline at end of file diff --git a/doc/changelog.d/1612.dependencies.md b/doc/changelog.d/1612.dependencies.md deleted file mode 100644 index acf0573c8a..0000000000 --- a/doc/changelog.d/1612.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump scipy from 1.14.1 to 1.15.0 \ No newline at end of file diff --git a/doc/changelog.d/1615.maintenance.md b/doc/changelog.d/1615.maintenance.md deleted file mode 100644 index c655a0905a..0000000000 --- a/doc/changelog.d/1615.maintenance.md +++ /dev/null @@ -1 +0,0 @@ -pre-commit automatic update \ No newline at end of file diff --git a/doc/changelog.d/1616.dependencies.md b/doc/changelog.d/1616.dependencies.md deleted file mode 100644 index 3c15bbc284..0000000000 --- a/doc/changelog.d/1616.dependencies.md +++ /dev/null @@ -1 +0,0 @@ -bump trame-vtk from 2.8.12 to 2.8.13 \ No newline at end of file diff --git a/doc/changelog.d/1706.maintenance.md b/doc/changelog.d/1706.maintenance.md new file mode 100644 index 0000000000..6f4c1c0089 --- /dev/null +++ b/doc/changelog.d/1706.maintenance.md @@ -0,0 +1 @@ +update CHANGELOG for v0.8.2 \ No newline at end of file diff --git a/doc/changelog.d/1707.added.md b/doc/changelog.d/1707.added.md new file mode 100644 index 0000000000..2602f1720f --- /dev/null +++ b/doc/changelog.d/1707.added.md @@ -0,0 +1 @@ +design activation changes \ No newline at end of file diff --git a/doc/source/_static/thumbnails/block_with_parameters.png b/doc/source/_static/thumbnails/block_with_parameters.png new file mode 100644 index 0000000000..f9a40a79eb Binary files /dev/null and b/doc/source/_static/thumbnails/block_with_parameters.png differ diff --git a/doc/source/_static/thumbnails/chamfer.png b/doc/source/_static/thumbnails/chamfer.png new file mode 100644 index 0000000000..89a78bc9b4 Binary files /dev/null and b/doc/source/_static/thumbnails/chamfer.png differ diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index ecfee60aea..3583da64d4 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -9,6 +9,186 @@ This document contains the release notes for the PyAnsys Geometry project. .. towncrier release notes start +`0.8.2 `_ - 2025-01-29 +===================================================================================== + +Added +^^^^^ + +- create a fillet on an edge/face `#1621 `_ +- create a full fillet between multiple faces `#1623 `_ +- extrude existing faces, setup face offset relationships `#1628 `_ +- interference repair tool `#1633 `_ +- extrude existing edges to create surface bodies `#1638 `_ +- create and modify linear patterns `#1641 `_ +- body suppression state `#1643 `_ +- parameters refurbished `#1647 `_ +- rename object `#1648 `_ +- surface body from trimmed curves `#1650 `_ +- create circular and fill patterns `#1653 `_ +- find fix simplify `#1661 `_ +- replace face `#1664 `_ +- commands for merge and intersect `#1665 `_ +- revolve faces a set distance, up to another object, or by a helix `#1666 `_ +- add split body and tests `#1669 `_ +- enable get/set persistent ids for stride import/export `#1671 `_ +- find and fix edge methods `#1672 `_ +- shell methods `#1673 `_ +- implementation of NURBS curves `#1675 `_ +- get assigned material `#1684 `_ +- matrix rotation and translation `#1689 `_ +- is_core_service BackendType static method `#1692 `_ +- export and download stride format `#1698 `_ +- blitz development `#1701 `_ + + +Dependencies +^^^^^^^^^^^^ + +- bump ansys-tools-visualization-interface from 0.7.0 to 0.8.1 `#1640 `_ +- bump ansys-api-geometry from 0.4.27 to 0.4.28 `#1644 `_ +- bump sphinx-autodoc-typehints from 3.0.0 to 3.0.1 in the docs-deps group `#1651 `_ +- bump ansys-api-geometry from 0.4.28 to 0.4.30 `#1652 `_ +- bump protobuf from 5.28.3 to 5.29.3 in the grpc-deps group across 1 directory `#1656 `_ +- bump numpy from 2.2.1 to 2.2.2 `#1662 `_ +- bump ansys-api-geometry from 0.4.30 to 0.4.31 `#1663 `_ +- bump ansys api geometry from 0.4.30 to 0.4.32 `#1677 `_ +- bump ansys-api-geometry from 0.4.31 to 0.4.32 `#1681 `_ +- bump panel from 1.5.5 to 1.6.0 `#1682 `_ +- bump semver from 3.0.2 to 3.0.4 `#1687 `_ +- bump ansys-api-geometry from 0.4.32 to 0.4.33 `#1695 `_ +- bump nbconvert from 7.16.5 to 7.16.6 in the docs-deps group `#1700 `_ + + +Fixed +^^^^^ + +- reactivate test on failing extra edges test `#1396 `_ +- filter set export id to only CoreService based backends `#1685 `_ +- cleanup unsupported module `#1690 `_ +- disable unimplemented tests `#1691 `_ +- tech review fixes for blitz branch `#1703 `_ + + +Maintenance +^^^^^^^^^^^ + +- update CHANGELOG for v0.8.1 `#1639 `_ +- whitelist semver package temporarily `#1657 `_ +- reverting semver package whitelist since problematic version is yanked `#1659 `_ +- pre-commit automatic update `#1667 `_, `#1696 `_ +- ensure design is closed on test exit `#1680 `_ +- use dedicate pygeometry-ci-2 runner `#1693 `_ +- remove towncrier info duplicates `#1702 `_ + + +Test +^^^^ + +- add more find and fix tests for repair tools `#1645 `_ +- Add some new tests `#1670 `_ +- add unit tests for 3 repair tools `#1683 `_ + +`0.8.1 `_ - 2025-01-15 +===================================================================================== + +Dependencies +^^^^^^^^^^^^ + +- bump ansys-api-geometry from 0.4.26 to 0.4.27 `#1634 `_ + + +Fixed +^^^^^ + +- release issues encountered `#1637 `_ + + +Maintenance +^^^^^^^^^^^ + +- update CHANGELOG for v0.8.0 `#1636 `_ + +`0.8.0 `_ - 2025-01-15 +===================================================================================== + +Added +^^^^^ + +- active support for Python 3.13 `#1481 `_ +- add chamfer tool `#1495 `_ +- allow version input to automatically consider the nuances for the Ansys Student version `#1548 `_ +- adapt health check timeout algorithm `#1559 `_ +- add core service support `#1571 `_ +- enable (partially) prepare and repair tools in Core Service `#1580 `_ +- create launcher for core services `#1587 `_ + + +Dependencies +^^^^^^^^^^^^ + +- bump ansys-api-geometry from 0.4.16 to 0.4.17 `#1547 `_ +- bump ansys-sphinx-theme[autoapi] from 1.2.1 to 1.2.2 in the docs-deps group `#1549 `_ +- bump ansys-api-geometry from 0.4.17 to 0.4.18 `#1550 `_ +- bump ansys-tools-visualization-interface from 0.5.0 to 0.6.0 `#1554 `_ +- bump pytest from 8.3.3 to 8.3.4 `#1562 `_ +- bump six from 1.16.0 to 1.17.0 `#1568 `_ +- bump the docs-deps group across 1 directory with 2 updates `#1570 `_ +- bump ansys-api-geometry from 0.4.18 to 0.4.20 `#1574 `_ +- bump numpy from 2.1.3 to 2.2.0 `#1575 `_ +- bump ansys-api-geometry from 0.4.20 to 0.4.23 `#1581 `_ +- bump ansys-api-geometry from 0.4.23 to 0.4.24 `#1582 `_ +- bump ansys-tools-visualization-interface from 0.6.0 to 0.6.1 `#1583 `_ +- bump ansys-tools-visualization-interface from 0.6.1 to 0.6.2 `#1586 `_ +- avoid the usage of attrs 24.3.0 (temporary) `#1589 `_ +- bump jupytext from 1.16.4 to 1.16.5 in the docs-deps group `#1590 `_ +- bump jupytext from 1.16.5 to 1.16.6 in the docs-deps group `#1593 `_ +- bump panel from 1.5.4 to 1.5.5 `#1595 `_ +- bump ansys-sphinx-theme[autoapi] from 1.2.3 to 1.2.4 in the docs-deps group `#1597 `_ +- bump notebook from 7.3.1 to 7.3.2 in the docs-deps group `#1598 `_ +- bump numpy from 2.2.0 to 2.2.1 `#1599 `_ +- bump ansys-tools-path from 0.7.0 to 0.7.1 `#1600 `_ +- bump nbsphinx from 0.9.5 to 0.9.6 in the docs-deps group `#1602 `_ +- bump nbconvert from 7.16.4 to 7.16.5 in the docs-deps group `#1609 `_ +- bump ansys-api-geometry from 0.4.24 to 0.4.25 `#1610 `_ +- bump sphinx-autodoc-typehints from 2.5.0 to 3.0.0 in the docs-deps group `#1611 `_ +- bump scipy from 1.14.1 to 1.15.0 `#1612 `_ +- bump trame-vtk from 2.8.12 to 2.8.13 `#1616 `_ +- bump trame-vtk from 2.8.13 to 2.8.14 `#1617 `_ +- bump ansys-tools-visualization-interface from 0.6.2 to 0.7.0 `#1619 `_ +- bump ansys-sphinx-theme[autoapi] from 1.2.4 to 1.2.6 in the docs-deps group `#1624 `_ +- bump scipy from 1.15.0 to 1.15.1 `#1625 `_ +- bump ansys-api-geometry from 0.4.25 to 0.4.26 `#1626 `_ + + +Documentation +^^^^^^^^^^^^^ + +- Explain how to report a security issue. `#1605 `_ + + +Fixed +^^^^^ + +- numpydoc warnings `#1556 `_ +- vtk/pyvista issues `#1584 `_ +- make_child_logger only takes 2 args. `#1603 `_ +- FAQ on install `#1631 `_ + + +Maintenance +^^^^^^^^^^^ + +- pre-commit automatic update `#1366 `_, `#1552 `_, `#1561 `_, `#1588 `_, `#1601 `_, `#1615 `_, `#1630 `_ +- update CHANGELOG for v0.7.6 `#1545 `_ +- change release artifacts self-hosted runner `#1546 `_ +- automerge pre-commit.ci PRs `#1553 `_ +- bump pyvista/setup-headless-display-action to v3 `#1555 `_ +- decouple unstable image promotion `#1591 `_ +- skip unnecessary stages when containers are the same `#1592 `_ +- Numpy is already imported at the top of the module. `#1604 `_ +- update license year using pre-commit hook `#1608 `_ + `0.7.6 `_ - 2024-11-19 ===================================================================================== diff --git a/doc/source/conf.py b/doc/source/conf.py index 7758b3cc06..8b3fbc52e9 100755 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -57,10 +57,11 @@ def get_wheelhouse_assets_dictionary(): time.sleep(2) if content is None: - raise requests.exceptions.RequestException("Failed to retrieve the latest release.") - - # Just point to the latest version - assets_context_version = json.loads(content)["name"] + print("Adapting URL to point to the latest version... (hack)") + assets_context_version = "dev" + else: + # Just point to the latest version + assets_context_version = json.loads(content)["name"] else: assets_context_version = f"v{__version__}" @@ -68,12 +69,16 @@ def get_wheelhouse_assets_dictionary(): for assets_os, assets_runner in zip(assets_context_os, assets_context_runners): download_links = [] for assets_py_ver in assets_context_python_versions: + if assets_context_version == "dev": + prefix_url = "https://github.com/ansys/pyansys-geometry/releases/latest/download" + else: + prefix_url = f"https://github.com/ansys/pyansys-geometry/releases/download/{assets_context_version}" temp_dict = { "os": assets_os, "runner": assets_runner, "python_versions": assets_py_ver, "latest_released_version": assets_context_version, - "prefix_url": f"https://github.com/ansys/pyansys-geometry/releases/download/{assets_context_version}", # noqa: E501 + "prefix_url": prefix_url, } download_links.append(temp_dict) @@ -303,6 +308,8 @@ def intersphinx_pyansys_geometry(switcher_version: str): "examples/03_modeling/design_tree": "_static/thumbnails/design_tree.png", "examples/03_modeling/service_colors": "_static/thumbnails/service_colors.png", "examples/03_modeling/surface_bodies": "_static/thumbnails/quarter_sphere.png", + "examples/03_modeling/design_parameters": "_static/thumbnails/block_with_parameters.png", + "examples/03_modeling/chamfer": "_static/thumbnails/chamfer.png", "examples/04_applied/01_naca_airfoils": "_static/thumbnails/naca_airfoils.png", "examples/04_applied/02_naca_fluent": "_static/thumbnails/naca_fluent.png", } diff --git a/doc/source/examples.rst b/doc/source/examples.rst index fc4c5abb1d..d4e75b5a67 100644 --- a/doc/source/examples.rst +++ b/doc/source/examples.rst @@ -49,6 +49,8 @@ These examples demonstrate service-based modeling operations. examples/03_modeling/design_tree.mystnb examples/03_modeling/service_colors.mystnb examples/03_modeling/surface_bodies.mystnb + examples/03_modeling/design_parameters.mystnb + examples/03_modeling/chamfer.mystnb Applied examples ---------------- diff --git a/doc/source/examples/03_modeling/.gitignore b/doc/source/examples/03_modeling/.gitignore index 2a79eda084..451daaeffe 100644 --- a/doc/source/examples/03_modeling/.gitignore +++ b/doc/source/examples/03_modeling/.gitignore @@ -1 +1,2 @@ -*.scdocx \ No newline at end of file +*.scdocx +*.dsco \ No newline at end of file diff --git a/doc/source/examples/03_modeling/boolean_operations.mystnb b/doc/source/examples/03_modeling/boolean_operations.mystnb index 26727f9005..afa20b75eb 100644 --- a/doc/source/examples/03_modeling/boolean_operations.mystnb +++ b/doc/source/examples/03_modeling/boolean_operations.mystnb @@ -19,8 +19,6 @@ This example shows how to use Boolean operations for geometry manipulation. Perform the required imports. ```{code-cell} ipython3 -from typing import List - from ansys.geometry.core import launch_modeler from ansys.geometry.core.designer import Body from ansys.geometry.core.math import Point2D @@ -91,7 +89,7 @@ output list is sorted according to the picking order. pl = GeometryPlotter(allow_picking=True) pl.plot(design.bodies) pl.show() -bodies: List[Body] = GeometryPlotter(allow_picking=True).show(design.bodies) +bodies: list[Body] = GeometryPlotter(allow_picking=True).show(design.bodies) ``` Otherwise, you can select bodies from the design directly. diff --git a/doc/source/examples/03_modeling/chamfer.mystnb b/doc/source/examples/03_modeling/chamfer.mystnb new file mode 100644 index 0000000000..d525376920 --- /dev/null +++ b/doc/source/examples/03_modeling/chamfer.mystnb @@ -0,0 +1,63 @@ +--- +jupytext: + text_representation: + extension: .mystnb + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Modeling: Chamfer edges and faces +A chamfer is an angled cut on an edge. Chamfers can be created using the ``Modeler.geometry_commands`` module. + ++++ + +## Create a block +Launch the modeler and create a block. + +```{code-cell} ipython3 +from ansys.geometry.core import launch_modeler, Modeler + +modeler = Modeler() +print(modeler) +``` + +```{code-cell} ipython3 +from ansys.geometry.core.sketch import Sketch +from ansys.geometry.core.math import Point2D + +design = modeler.create_design("chamfer_block") +body = design.extrude_sketch("block", Sketch().box(Point2D([0, 0]), 1, 1), 1) + +body.plot() +``` + +## Chamfer edges +Create a uniform chamfer on all edges of the block. + +```{code-cell} ipython3 +modeler.geometry_commands.chamfer(body.edges, distance=0.1) + +body.plot() +``` + +## Chamfer faces +The chamfer of a face can also be modified. Create a chamfer on a single edge and then modify the chamfer distance value by providing the newly created face that represents the chamfer. + +```{code-cell} ipython3 +body = design.extrude_sketch("box", Sketch().box(Point2D([0,0]), 1, 1), 1) + +modeler.geometry_commands.chamfer(body.edges[0], distance=0.1) + +body.plot() +``` + +```{code-cell} ipython3 +modeler.geometry_commands.chamfer(body.faces[-1], distance=0.3) + +body.plot() +``` diff --git a/doc/source/examples/03_modeling/design_parameters.mystnb b/doc/source/examples/03_modeling/design_parameters.mystnb new file mode 100644 index 0000000000..a407e24a22 --- /dev/null +++ b/doc/source/examples/03_modeling/design_parameters.mystnb @@ -0,0 +1,146 @@ +--- +jupytext: + text_representation: + extension: .mystnb + format_name: myst + format_version: 0.13 + jupytext_version: 1.16.4 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +# Modeling: Using design parameters + +You can read and update parameters that are part of the design. +The simple design in this example has two associated parameters. + ++++ + +## Perform required imports + +```{code-cell} ipython3 +from pathlib import Path +import requests + +from ansys.geometry.core import launch_modeler +``` + +The file for this example is in the integration tests folder and can be downloaded. + ++++ + +## Download the example file + ++++ + +Download the file for this example from the integration tests folder in the PyAnsys Geometry repository. + +```{code-cell} ipython3 +def download_file(url, filename): + """Download a file from a URL and save it to a local file.""" + response = requests.get(url) + response.raise_for_status() # Check if the request was successful + with open(filename, 'wb') as file: + file.write(response.content) + +# URL of the file to download +url = "https://raw.githubusercontent.com/ansys/pyansys-geometry/main/tests/integration/files/blockswithparameters.dsco" + +# Local path to save the file to +file_path = Path.cwd() / "blockswithparameters.dsco" + +# Download the file +download_file(url, file_path) +print(f"File is downloaded to {file_path}") +``` + +## Import a design with parameters + ++++ + +Import the model using the ``open_file()`` method of the modeler. + +```{code-cell} ipython3 +# Create a modeler object +modeler = launch_modeler() +design = modeler.open_file(file_path) +design.plot() +``` + +## Read existing parameters of the design + +You can get all the parameters of the design as a list of parameters. Because this +example has two parameters, you see two items in the list. + +```{code-cell} ipython3 +my_parameters = design.parameters +print(len(my_parameters)) +``` + +A parameter object has a name, value, and unit. + +```{code-cell} ipython3 +print(my_parameters[0].name) +print(my_parameters[0].dimension_value) +print(my_parameters[0].dimension_type) + +print(my_parameters[1].name) +print(my_parameters[1].dimension_value) +print(my_parameters[1].dimension_type) +``` + +Parameter values are returned in the default unit for each dimension type. Since default length +unit is meter and default area unit is meter square, the value is returned in square meters. + ++++ + +## Edit a parameter value + +You can edit the parameter's name or value by simply setting these fields. +Set the second parameter (p2 value to 350 mm). + +```{code-cell} ipython3 +parameter1 = my_parameters[1] +parameter1.dimension_value = 0.000440 +response = design.set_parameter(parameter1) +print(response) +print(my_parameters[0].dimension_value) +print(my_parameters[1].dimension_value) +``` + +After a successful parameter update, the design is updated. If we request the design +plot again, we see the updated design. + +```{code-cell} ipython3 +design.plot() +``` + +The ``set_parameter()`` method returns a ``Success`` status message if the parameter is updated or +a "FAILURE" status message if the update fails. If the ``p2`` parameter depends on the ``p1`` +parameter, updating the ``p1`` parameter might also change the ``p2`` parameter. In such cases, +the method returns ``CONSTRAINED_PARAMETERS``, which indicates other parameters were also updated. + +```{code-cell} ipython3 +parameter1 = my_parameters[0] +parameter1.dimension_value = 0.000250 +response = design.set_parameter(parameter1) +print(response) +``` + +To get the updated list, query the parameters once again. + +```{code-cell} ipython3 +my_parameters = design.parameters +print(my_parameters[0].dimension_value) +print(my_parameters[1].dimension_value) +``` + +## Close the modeler + +Close the modeler to free up resources and release the connection. + +```{code-cell} ipython3 +modeler.close() +``` diff --git a/doc/source/examples/04_applied/01_naca_airfoils.mystnb b/doc/source/examples/04_applied/01_naca_airfoils.mystnb index 87d74e195a..df34995454 100644 --- a/doc/source/examples/04_applied/01_naca_airfoils.mystnb +++ b/doc/source/examples/04_applied/01_naca_airfoils.mystnb @@ -43,7 +43,7 @@ import numpy as np from ansys.geometry.core.math import Point2D -def naca_airfoil_4digits(number: Union[int, str], n_points: int = 200) -> List[Point2D]: +def naca_airfoil_4digits(number: Union[int, str], n_points: int = 200) -> list[Point2D]: """ Generate a NACA 4-digits airfoil. @@ -58,7 +58,7 @@ def naca_airfoil_4digits(number: Union[int, str], n_points: int = 200) -> List[P Returns ------- - List[Point2D] + list[Point2D] List of points that define the airfoil. """ # Check if the number is a string diff --git a/doc/source/getting_started/faq.rst b/doc/source/getting_started/faq.rst index a7713cae4c..8c9ad4e412 100644 --- a/doc/source/getting_started/faq.rst +++ b/doc/source/getting_started/faq.rst @@ -24,10 +24,8 @@ The Ansys Geometry service is available as a standalone service and it is instal through the Ansys unified installer or the automated installer. Both are available for download from the `Ansys Customer Portal `_. -When using the automated installer, the Ansys Geometry service is installed by default. - -However, when using the unified installer, it is necessary to pass in the ``-geometryservice`` -flag to install it. +When using the unified or automated installer, it is necessary to pass in the +``-geometryservice`` flag to install it. Overall, the command to install the Ansys Geometry service with the unified installer is: diff --git a/docker/build_docker_windows.py b/docker/build_docker_windows.py index a707c7123f..c1617ddf9e 100644 --- a/docker/build_docker_windows.py +++ b/docker/build_docker_windows.py @@ -54,7 +54,7 @@ # Request the user to select the version of Ansys to use print(">>> Select the version of Ansys to use:") for i, (env_key, _) in enumerate(awp_root.items()): - print(f"{i+1}: {env_key}") + print(f"{i + 1}: {env_key}") selection = input("Selection [default - last option]: ") # If no selection is made, use the first version diff --git a/docker/linux/coreservice/Dockerfile b/docker/linux/coreservice/Dockerfile index 56fb4341c8..cae53ba2a1 100644 --- a/docker/linux/coreservice/Dockerfile +++ b/docker/linux/coreservice/Dockerfile @@ -17,9 +17,9 @@ RUN apt-get update && \ COPY linux-core-binaries.zip . RUN unzip -qq linux-core-binaries.zip -d . && \ rm linux-core-binaries.zip && \ - chmod -R 0755 DockerLinux && \ - mv DockerLinux/bin/x64/Release_Linux/net8.0/* . && \ - rm -rf DockerLinux + chmod -R 0755 bin && \ + mv bin/x64/Release_Core_Linux/net8.0/* . && \ + rm -rf bin # Let the dynamic link loader where to search for shared libraries ENV LD_LIBRARY_PATH=/app:/app/CADIntegration/bin:/app/Native/Linux diff --git a/docker/windows/coreservice/Dockerfile b/docker/windows/coreservice/Dockerfile index 5bea59526a..5799c990d3 100644 --- a/docker/windows/coreservice/Dockerfile +++ b/docker/windows/coreservice/Dockerfile @@ -21,8 +21,8 @@ WORKDIR /app COPY windows-core-binaries.zip . RUN mkdir tmp_folder && \ tar -xf windows-core-binaries.zip -C tmp_folder && \ - xcopy tmp_folder\DockerWindows\bin\x64\Release_Core_Windows\net8.0\* . /e /i /h && \ - xcopy tmp_folder\DockerWindows\* . && \ + xcopy tmp_folder\bin\x64\Release_Core_Windows\net8.0\* . /e /i /h && \ + xcopy tmp_folder\* . && \ del windows-core-binaries.zip && \ rmdir /s /q tmp_folder @@ -47,4 +47,5 @@ LABEL org.opencontainers.image.vendor="ANSYS Inc." EXPOSE 50051 # Define the entrypoint for the Geometry service -ENTRYPOINT ["dotnet", "C:\app\Presentation.ApiServerLinux.dll"] +# hadolint ignore=DL3025 +ENTRYPOINT dotnet C:\app\Presentation.ApiServerLinux.dll diff --git a/docker/windows/dms/Dockerfile b/docker/windows/dms/Dockerfile index 2a0ec9d8a0..4a2396f130 100644 --- a/docker/windows/dms/Dockerfile +++ b/docker/windows/dms/Dockerfile @@ -19,8 +19,8 @@ WORKDIR /app COPY windows-dms-binaries.zip . RUN mkdir tmp_folder && \ tar -xf windows-dms-binaries.zip -C tmp_folder && \ - xcopy tmp_folder\DockerWindows\bin\x64\Release_Headless\net472\* . /e /i /h && \ - xcopy tmp_folder\DockerWindows\* . && \ + xcopy tmp_folder\bin\x64\Release_Headless\net472\* . /e /i /h && \ + xcopy tmp_folder\* . && \ del windows-dms-binaries.zip && \ rmdir /s /q tmp_folder @@ -29,7 +29,7 @@ ENV LICENSE_SERVER="" ENV SERVER_ENDPOINT="0.0.0.0:50051" ENV ENABLE_TRACE=0 ENV LOG_LEVEL=2 -ENV AWP_ROOT251=C:/app/unified +ENV AWP_ROOT252=C:/app/unified # Add container labels LABEL org.opencontainers.image.authors="ANSYS Inc." diff --git a/pyproject.toml b/pyproject.toml index 304857b3d6..2e839a7acb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,12 @@ classifiers = [ ] dependencies = [ - "ansys-api-geometry==0.4.25", + "ansys-api-geometry==0.4.33", "ansys-tools-path>=0.3,<1", "ansys-tools-visualization-interface>=0.2.6,<1", "attrs!=24.3.0", "beartype>=0.11.0,<0.20", + "geomdl>=5,<6", "grpcio>=1.35.0,<1.68", "grpcio-health-checking>=1.45.0,<1.68", "numpy>=1.20.3,<3", @@ -51,22 +52,23 @@ all = [ tests = [ "ansys-platform-instancemanagement==1.1.2", "ansys-tools-path==0.7.1", - "ansys-tools-visualization-interface==0.6.2", + "ansys-tools-visualization-interface==0.8.1", "beartype==0.19.0", "docker==7.1.0", + "geomdl==5.3.1", "grpcio==1.67.1", "grpcio-health-checking==1.67.1", - "numpy==2.2.1", + "numpy==2.2.2", "Pint==0.24.4", - "protobuf==5.28.3", + "protobuf==5.29.3", "pytest==8.3.4", "pytest-cov==6.0.0", "pytest-pyvista==0.1.9", "pytest-xvfb==3.0.0", "pyvista[jupyter]==0.44.2", "requests==2.32.3", - "scipy==1.15.0", - "semver==3.0.2", + "scipy==1.15.1", + "semver==3.0.4", "six==1.17.0", "vtk==9.3.1", ] @@ -77,35 +79,36 @@ tests-minimal = [ "pytest-xvfb==3.0.0", ] doc = [ - "ansys-sphinx-theme[autoapi]==1.2.4", + "ansys-sphinx-theme[autoapi]==1.2.6", "ansys-tools-path==0.7.1", - "ansys-tools-visualization-interface==0.6.2", + "ansys-tools-visualization-interface==0.8.1", "beartype==0.19.0", "docker==7.1.0", + "geomdl==5.3.1", "grpcio==1.67.1", "grpcio-health-checking==1.67.1", "ipyvtklink==0.2.3", "jupyter_sphinx==0.5.3", "jupytext==1.16.6", "myst-parser==4.0.0", - "nbconvert==7.16.5", + "nbconvert==7.16.6", "nbsphinx==0.9.6", "notebook==7.3.2", "numpydoc==1.8.0", - "numpy==2.2.1", - "panel==1.5.5", + "numpy==2.2.2", + "panel==1.6.0", "Pint==0.24.4", - "protobuf==5.28.3", + "protobuf==5.29.3", "pyvista[jupyter]==0.44.2", "requests==2.32.3", - "scipy==1.15.0", - "semver==3.0.2", + "scipy==1.15.1", + "semver==3.0.4", "six==1.17.0", "sphinx==8.1.3", - "sphinx-autodoc-typehints==3.0.0", + "sphinx-autodoc-typehints==3.0.1", "sphinx-copybutton==0.5.2", "sphinx-jinja==2.0.2", - "trame-vtk==2.8.13", + "trame-vtk==2.8.14", "vtk==9.3.1", ] @@ -210,16 +213,6 @@ directory = "miscellaneous" name = "Miscellaneous" showcontent = true -[[tool.towncrier.type]] -directory = "documentation" -name = "Documentation" -showcontent = true - -[[tool.towncrier.type]] -directory = "maintenance" -name = "Maintenance" -showcontent = true - [[tool.towncrier.type]] directory = "test" name = "Test" diff --git a/src/ansys/geometry/core/connection/__init__.py b/src/ansys/geometry/core/connection/__init__.py index 15075ecf6e..f765a87a85 100644 --- a/src/ansys/geometry/core/connection/__init__.py +++ b/src/ansys/geometry/core/connection/__init__.py @@ -30,6 +30,7 @@ grpc_frame_to_frame, grpc_matrix_to_matrix, grpc_surface_to_surface, + line_to_grpc_line, plane_to_grpc_plane, point3d_to_grpc_point, sketch_shapes_to_grpc_geometries, diff --git a/src/ansys/geometry/core/connection/backend.py b/src/ansys/geometry/core/connection/backend.py index 932a94cb7b..9ce27a94a1 100644 --- a/src/ansys/geometry/core/connection/backend.py +++ b/src/ansys/geometry/core/connection/backend.py @@ -33,6 +33,29 @@ class BackendType(Enum): SPACECLAIM = 1 WINDOWS_SERVICE = 2 LINUX_SERVICE = 3 + CORE_WINDOWS = 4 + CORE_LINUX = 5 + DISCOVERY_HEADLESS = 6 + + @staticmethod + def is_core_service(backend_type: "BackendType") -> bool: + """Determine whether the backend is CoreService based or not. + + Parameters + ---------- + backend_type : BackendType + The backend type to check whether or not it's a CoreService type. + + Returns + ------- + bool + True if the backend is CoreService based, False otherwise. + """ + return backend_type in ( + BackendType.LINUX_SERVICE, + BackendType.CORE_WINDOWS, + BackendType.CORE_LINUX, + ) class ApiVersions(Enum): diff --git a/src/ansys/geometry/core/connection/client.py b/src/ansys/geometry/core/connection/client.py index 96341c420c..fe7a7484e8 100644 --- a/src/ansys/geometry/core/connection/client.py +++ b/src/ansys/geometry/core/connection/client.py @@ -215,11 +215,26 @@ def __init__( backend_type = BackendType.WINDOWS_SERVICE elif grpc_backend_type == GRPCBackendType.LINUX_DMS: backend_type = BackendType.LINUX_SERVICE + elif grpc_backend_type == GRPCBackendType.CORE_SERVICE_LINUX: + backend_type = BackendType.CORE_LINUX + elif grpc_backend_type == GRPCBackendType.CORE_SERVICE_WINDOWS: + backend_type = BackendType.CORE_WINDOWS + elif grpc_backend_type == GRPCBackendType.DISCOVERY_HEADLESS: + backend_type = BackendType.DISCOVERY_HEADLESS # Store the backend type self._backend_type = backend_type self._multiple_designs_allowed = ( - False if backend_type in (BackendType.DISCOVERY, BackendType.LINUX_SERVICE) else True + False + if backend_type + in ( + BackendType.DISCOVERY, + BackendType.LINUX_SERVICE, + BackendType.CORE_LINUX, + BackendType.CORE_WINDOWS, + BackendType.DISCOVERY_HEADLESS, + ) + else True ) # retrieve the backend version diff --git a/src/ansys/geometry/core/connection/conversions.py b/src/ansys/geometry/core/connection/conversions.py index 462b8936d0..6e8bd31eb4 100644 --- a/src/ansys/geometry/core/connection/conversions.py +++ b/src/ansys/geometry/core/connection/conversions.py @@ -23,6 +23,8 @@ from typing import TYPE_CHECKING +from pint import Quantity, UndefinedUnitError + from ansys.api.geometry.v0.models_pb2 import ( Arc as GRPCArc, Circle as GRPCCircle, @@ -32,6 +34,8 @@ Frame as GRPCFrame, Geometries as GRPCGeometries, Line as GRPCLine, + Material as GRPCMaterial, + MaterialProperty as GRPCMaterialProperty, Matrix as GRPCMatrix, Plane as GRPCPlane, Point as GRPCPoint, @@ -42,12 +46,17 @@ TrimmedCurve as GRPCTrimmedCurve, TrimmedSurface as GRPCTrimmedSurface, ) +from ansys.geometry.core.materials.material import ( + Material, + MaterialProperty, + MaterialPropertyType, +) from ansys.geometry.core.math.frame import Frame from ansys.geometry.core.math.matrix import Matrix44 from ansys.geometry.core.math.plane import Plane from ansys.geometry.core.math.point import Point2D, Point3D from ansys.geometry.core.math.vector import UnitVector3D -from ansys.geometry.core.misc.measurements import DEFAULT_UNITS +from ansys.geometry.core.misc.measurements import DEFAULT_UNITS, UNITS from ansys.geometry.core.shapes.curves.circle import Circle from ansys.geometry.core.shapes.curves.curve import Curve from ansys.geometry.core.shapes.curves.ellipse import Ellipse @@ -662,3 +671,76 @@ def trimmed_surface_to_grpc_trimmed_surface( v_min=trimmed_surface.box_uv.interval_v.start, v_max=trimmed_surface.box_uv.interval_v.end, ) + + +def line_to_grpc_line(line: Line) -> GRPCLine: + """Convert a ``Line`` to a line gRPC message. + + Parameters + ---------- + line : Line + Line to convert. + + Returns + ------- + GRPCLine + Geometry service gRPC ``Line`` message. + """ + start = line.origin + end = line.origin + line.direction + return GRPCLine(start=point3d_to_grpc_point(start), end=point3d_to_grpc_point(end)) + + +def grpc_material_to_material(material: GRPCMaterial) -> Material: + """Convert a material gRPC message to a ``Material`` class. + + Parameters + ---------- + material : GRPCMaterial + Material gRPC message. + + Returns + ------- + Material + Converted material. + """ + properties = [] + density = Quantity(0, UNITS.kg / UNITS.m**3) + for property in material.material_properties: + mp = grpc_material_property_to_material_property(property) + properties.append(mp) + if mp.type == MaterialPropertyType.DENSITY: + density = mp.quantity + + return Material(material.name, density, properties) + + +def grpc_material_property_to_material_property( + material_property: GRPCMaterialProperty, +) -> MaterialProperty: + """Convert a material property gRPC message to a ``MaterialProperty`` class. + + Parameters + ---------- + material_property : GRPCMaterialProperty + Material property gRPC message. + + Returns + ------- + MaterialProperty + Converted material property. + """ + try: + mp_type = MaterialPropertyType.from_id(material_property.id) + except ValueError: + mp_type = material_property.id + + try: + mp_quantity = Quantity(material_property.value, material_property.units) + except ( + UndefinedUnitError, + TypeError, + ): + mp_quantity = material_property.value + + return MaterialProperty(mp_type, material_property.display_name, mp_quantity) diff --git a/src/ansys/geometry/core/connection/product_instance.py b/src/ansys/geometry/core/connection/product_instance.py index a569ba38e5..1bf5481c16 100644 --- a/src/ansys/geometry/core/connection/product_instance.py +++ b/src/ansys/geometry/core/connection/product_instance.py @@ -263,7 +263,10 @@ def prepare_and_start_backend( """ from ansys.geometry.core.modeler import Modeler - if os.name != "nt" and backend_type != BackendType.LINUX_SERVICE: # pragma: no cover + if os.name != "nt" and backend_type not in ( + BackendType.LINUX_SERVICE, + BackendType.CORE_LINUX, + ): # pragma: no cover raise RuntimeError( "Method 'prepare_and_start_backend' is only available on Windows." "A Linux version is only available for the Core Geometry Service." @@ -362,7 +365,7 @@ def prepare_and_start_backend( ) ) # This should be modified to Windows Core Service in the future - elif backend_type == BackendType.LINUX_SERVICE: + elif BackendType.is_core_service(backend_type): # Define several Ansys Geometry Core Service folders needed root_service_folder = Path(installations[product_version], CORE_GEOMETRY_SERVICE_FOLDER) native_folder = root_service_folder / "Native" diff --git a/src/ansys/geometry/core/designer/__init__.py b/src/ansys/geometry/core/designer/__init__.py index 81ee3d9b3f..aee2e7e966 100644 --- a/src/ansys/geometry/core/designer/__init__.py +++ b/src/ansys/geometry/core/designer/__init__.py @@ -27,5 +27,6 @@ from ansys.geometry.core.designer.designpoint import DesignPoint from ansys.geometry.core.designer.edge import CurveType, Edge from ansys.geometry.core.designer.face import Face, SurfaceType +from ansys.geometry.core.designer.geometry_commands import ExtrudeType, OffsetMode from ansys.geometry.core.designer.part import MasterComponent, Part from ansys.geometry.core.designer.selection import NamedSelection diff --git a/src/ansys/geometry/core/designer/body.py b/src/ansys/geometry/core/designer/body.py index 6a2b3e9b14..31638e471a 100644 --- a/src/ansys/geometry/core/designer/body.py +++ b/src/ansys/geometry/core/designer/body.py @@ -44,19 +44,25 @@ SetColorRequest, SetFillStyleRequest, SetNameRequest, + SetSuppressedRequest, TranslateRequest, ) from ansys.api.geometry.v0.bodies_pb2_grpc import BodiesStub from ansys.api.geometry.v0.commands_pb2 import ( AssignMidSurfaceOffsetTypeRequest, AssignMidSurfaceThicknessRequest, + CombineIntersectBodiesRequest, + CombineMergeBodiesRequest, ImprintCurvesRequest, ProjectCurvesRequest, + RemoveFacesRequest, + ShellRequest, ) from ansys.api.geometry.v0.commands_pb2_grpc import CommandsStub from ansys.geometry.core.connection.client import GrpcClient from ansys.geometry.core.connection.conversions import ( frame_to_grpc_frame, + grpc_material_to_material, plane_to_grpc_plane, point3d_to_grpc_point, sketch_shapes_to_grpc_geometries, @@ -73,8 +79,10 @@ from ansys.geometry.core.math.plane import Plane from ansys.geometry.core.math.point import Point3D from ansys.geometry.core.math.vector import UnitVector3D +from ansys.geometry.core.misc.auxiliary import get_design_from_body from ansys.geometry.core.misc.checks import ( check_type, + check_type_all_elements_in_iterable, ensure_design_is_active, min_backend_version, ) @@ -132,6 +140,11 @@ def id(self) -> str: """Get the ID of the body as a string.""" return + @abstractmethod + def _grpc_id(self) -> EntityIdentifier: + """Entity identifier of this body on the server side.""" + return + @abstractmethod def name(self) -> str: """Get the name of the body.""" @@ -152,6 +165,16 @@ def set_fill_style(self, fill_style: FillStyle) -> None: """Set the fill style of the body.""" return + @abstractmethod + def is_suppressed(self) -> bool: + """Get the body suppression state.""" + return + + @abstractmethod + def set_suppressed(self, suppressed: bool) -> None: + """Set the body suppression state.""" + return + @abstractmethod def color(self) -> str: """Get the color of the body.""" @@ -229,6 +252,17 @@ def volume(self) -> Quantity: """ return + @abstractmethod + def material(self) -> Material: + """Get the assigned material of the body. + + Returns + ------- + Material + Material assigned to the body. + """ + return + @abstractmethod def assign_material(self, material: Material) -> None: """Assign a material against the active design. @@ -240,6 +274,17 @@ def assign_material(self, material: Material) -> None: """ return + @abstractmethod + def get_assigned_material(self) -> Material: + """Get the assigned material of the body. + + Returns + ------- + Material + Material assigned to the body. + """ + return + @abstractmethod def add_midsurface_thickness(self, thickness: Quantity) -> None: """Add a mid-surface thickness to a surface body. @@ -541,6 +586,40 @@ def tessellate(self, merge: bool = False) -> Union["PolyData", "MultiBlock"]: """ return + @abstractmethod + def shell_body(self, offset: Real) -> bool: + """Shell the body to the thickness specified. + + Parameters + ---------- + offset : Real + Shell thickness. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + return + + @abstractmethod + def remove_faces(self, selection: Face | Iterable[Face], offset: Real) -> bool: + """Shell by removing a given set of faces. + + Parameters + ---------- + selection : Face | Iterable[Face] + Face or faces to be removed. + offset : Real + Shell thickness. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + return + @abstractmethod def plot( self, @@ -736,15 +815,14 @@ def wrapper(self: "MasterBody", *args, **kwargs): return wrapper - @property - def _grpc_id(self) -> EntityIdentifier: # noqa: D102 - """Entity identifier of this body on the server side.""" - return EntityIdentifier(id=self._id) - @property def id(self) -> str: # noqa: D102 return self._id + @property + def _grpc_id(self) -> EntityIdentifier: # noqa: D102 + return EntityIdentifier(id=self._id) + @property def name(self) -> str: # noqa: D102 return self._name @@ -762,6 +840,17 @@ def fill_style(self, value: FillStyle): # noqa: D102 self.set_fill_style(value) @property + @protect_grpc + def is_suppressed(self) -> bool: # noqa: D102 + response = self._bodies_stub.IsSuppressed(EntityIdentifier(id=self._id)) + return response.result + + @is_suppressed.setter + def is_suppressed(self, value: bool): # noqa: D102 + self.set_suppressed(value) + + @property + @protect_grpc def color(self) -> str: # noqa: D102 """Get the current color of the body.""" if self._color is None and self.is_alive: @@ -844,6 +933,14 @@ def volume(self) -> Quantity: # noqa: D102 volume_response = self._bodies_stub.GetVolume(self._grpc_id) return Quantity(volume_response.volume, DEFAULT_UNITS.SERVER_VOLUME) + @property + def material(self) -> Material: # noqa: D102 + return self.get_assigned_material() + + @material.setter + def material(self, value: Material): # noqa: D102 + self.assign_material(value) + @protect_grpc @check_input_types def assign_material(self, material: Material) -> None: # noqa: D102 @@ -852,6 +949,12 @@ def assign_material(self, material: Material) -> None: # noqa: D102 SetAssignedMaterialRequest(id=self._id, material=material.name) ) + @protect_grpc + def get_assigned_material(self) -> Material: # noqa: D102 + self._grpc_client.log.debug(f"Retrieving assigned material for body {self.id}.") + material_response = self._bodies_stub.GetAssignedMaterial(self._grpc_id) + return grpc_material_to_material(material_response) + @protect_grpc @check_input_types def add_midsurface_thickness(self, thickness: Quantity) -> None: # noqa: D102 @@ -977,6 +1080,21 @@ def set_fill_style( # noqa: D102 ) self._fill_style = fill_style + @protect_grpc + @check_input_types + @min_backend_version(25, 2, 0) + def set_suppressed( # noqa: D102 + self, suppressed: bool + ) -> None: + """Set the body suppression state.""" + self._grpc_client.log.debug(f"Setting body {self.id}, as suppressed: {suppressed}.") + self._bodies_stub.SetSuppressed( + SetSuppressedRequest( + bodies=[EntityIdentifier(id=self.id)], + is_suppressed=suppressed, + ) + ) + @protect_grpc @check_input_types @min_backend_version(25, 1, 0) @@ -1127,6 +1245,52 @@ def tessellate( # noqa: D102 else: return comp + @protect_grpc + @reset_tessellation_cache + @check_input_types + @min_backend_version(25, 2, 0) + def shell_body(self, offset: Real) -> bool: # noqa: D102 + self._grpc_client.log.debug(f"Shelling body {self.id} to offset {offset}.") + + result = self._commands_stub.Shell( + ShellRequest( + selection=self._grpc_id, + offset=offset, + ) + ) + + if result.success is False: + self._grpc_client.log.warning(f"Failed to shell body {self.id}.") + + return result.success + + @protect_grpc + @reset_tessellation_cache + @check_input_types + @min_backend_version(25, 2, 0) + def remove_faces(self, selection: Face | Iterable[Face], offset: Real) -> bool: # noqa: D102 + selection: list[Face] = selection if isinstance(selection, Iterable) else [selection] + check_type_all_elements_in_iterable(selection, Face) + + # check if faces belong to this body + for face in selection: + if face.body.id != self.id: + raise ValueError(f"Face {face.id} does not belong to body {self.id}.") + + self._grpc_client.log.debug(f"Removing faces to shell body {self.id}.") + + result = self._commands_stub.RemoveFaces( + RemoveFacesRequest( + selection=[face._grpc_id for face in selection], + offset=offset, + ) + ) + + if result.success is False: + self._grpc_client.log.warning(f"Failed to remove faces from body {self.id}.") + + return result.success + def plot( # noqa: D102 self, merge: bool = True, @@ -1207,15 +1371,35 @@ def reset_tessellation_cache(func): # noqa: N805 @wraps(func) def wrapper(self: "Body", *args, **kwargs): - self._template._tessellation = None + self._reset_tessellation_cache() return func(self, *args, **kwargs) return wrapper + def _reset_tessellation_cache(self): # noqa: N805 + """Reset the cached tessellation for a body.""" + self._template._tessellation = None + # if this reference is stale, reset the real cache in the part + # this gets the matching id master body in the part + master_in_part = next( + ( + b + for b in self.parent_component._master_component.part.bodies + if b.id == self._template.id + ), + None, + ) + if master_in_part is not None: + master_in_part._tessellation = None + @property def id(self) -> str: # noqa: D102 return self._id + @property + def _grpc_id(self) -> EntityIdentifier: # noqa: D102 + return EntityIdentifier(id=self._id) + @property def name(self) -> str: # noqa: D102 return self._template.name @@ -1232,6 +1416,14 @@ def fill_style(self) -> str: # noqa: D102 def fill_style(self, fill_style: FillStyle) -> str: # noqa: D102 self._template.fill_style = fill_style + @property + def is_suppressed(self) -> bool: # noqa: D102 + return self._template.is_suppressed + + @is_suppressed.setter + def is_suppressed(self, suppressed: bool): # noqa: D102 + self._template.is_suppressed = suppressed + @property def color(self) -> str: # noqa: D102 return self._template.color @@ -1323,10 +1515,23 @@ def surface_offset(self) -> Union["MidSurfaceOffsetType", None]: # noqa: D102 def volume(self) -> Quantity: # noqa: D102 return self._template.volume + @property + @ensure_design_is_active + def material(self) -> Material: # noqa: D102 + return self._template.material + + @material.setter + def material(self, value: Material): # noqa: D102 + self._template.material = value + @ensure_design_is_active def assign_material(self, material: Material) -> None: # noqa: D102 self._template.assign_material(material) + @ensure_design_is_active + def get_assigned_material(self) -> Material: # noqa: D102 + return self._template.get_assigned_material() + @ensure_design_is_active def add_midsurface_thickness(self, thickness: Quantity) -> None: # noqa: D102 self._template.add_midsurface_thickness(thickness) @@ -1467,6 +1672,10 @@ def set_name(self, name: str) -> None: # noqa: D102 def set_fill_style(self, fill_style: FillStyle) -> None: # noqa: D102 return self._template.set_fill_style(fill_style) + @ensure_design_is_active + def set_suppressed(self, suppressed: bool) -> None: # noqa: D102 + return self._template.set_suppressed(suppressed) + @ensure_design_is_active def set_color(self, color: str | tuple[float, float, float]) -> None: # noqa: D102 return self._template.set_color(color) @@ -1512,6 +1721,14 @@ def tessellate( # noqa: D102 ) -> Union["PolyData", "MultiBlock"]: return self._template.tessellate(merge, self.parent_component.get_world_transform()) + @ensure_design_is_active + def shell_body(self, offset: Real) -> bool: # noqa: D102 + return self._template.shell_body(offset) + + @ensure_design_is_active + def remove_faces(self, selection: Face | Iterable[Face], offset: Real) -> bool: # noqa: D102 + return self._template.remove_faces(selection, offset) + def plot( # noqa: D102 self, merge: bool = True, @@ -1544,13 +1761,79 @@ def plot( # noqa: D102 pl.show(screenshot=screenshot, **plotting_options) def intersect(self, other: Union["Body", Iterable["Body"]], keep_other: bool = False) -> None: # noqa: D102 - self.__generic_boolean_op(other, keep_other, "intersect", "bodies do not intersect") + if self._template._grpc_client.backend_version < (25, 2, 0): + self.__generic_boolean_op(other, keep_other, "intersect", "bodies do not intersect") + else: + self.__generic_boolean_command( + other, keep_other, "intersect", "bodies do not intersect" + ) def subtract(self, other: Union["Body", Iterable["Body"]], keep_other: bool = False) -> None: # noqa: D102 - self.__generic_boolean_op(other, keep_other, "subtract", "empty (complete) subtraction") + if self._template._grpc_client.backend_version < (25, 2, 0): + self.__generic_boolean_op(other, keep_other, "subtract", "empty (complete) subtraction") + else: + self.__generic_boolean_command( + other, keep_other, "subtract", "empty (complete) subtraction" + ) def unite(self, other: Union["Body", Iterable["Body"]], keep_other: bool = False) -> None: # noqa: D102 - self.__generic_boolean_op(other, keep_other, "unite", "union operation failed") + if self._template._grpc_client.backend_version < (25, 2, 0): + self.__generic_boolean_op(other, keep_other, "unite", "union operation failed") + else: + self.__generic_boolean_command(other, False, "unite", "union operation failed") + + @protect_grpc + @reset_tessellation_cache + @ensure_design_is_active + @check_input_types + def __generic_boolean_command( + self, + other: Union["Body", Iterable["Body"]], + keep_other: bool, + type_bool_op: str, + err_bool_op: str, + ) -> None: + parent_design = get_design_from_body(self) + other_bodies = other if isinstance(other, Iterable) else [other] + if type_bool_op == "intersect": + body_ids = [body._grpc_id for body in other_bodies] + target_ids = [self._grpc_id] + request = CombineIntersectBodiesRequest( + target_selection=target_ids, + tool_selection=body_ids, + subtract_from_target=False, + keep_cutter=keep_other, + ) + response = self._template._commands_stub.CombineIntersectBodies(request) + elif type_bool_op == "subtract": + body_ids = [body._grpc_id for body in other_bodies] + target_ids = [self._grpc_id] + request = CombineIntersectBodiesRequest( + target_selection=target_ids, + tool_selection=body_ids, + subtract_from_target=True, + keep_cutter=keep_other, + ) + response = self._template._commands_stub.CombineIntersectBodies(request) + elif type_bool_op == "unite": + bodies = [self] + bodies.extend(other_bodies) + body_ids = [body._grpc_id for body in bodies] + request = CombineMergeBodiesRequest(target_selection=body_ids) + response = self._template._commands_stub.CombineMergeBodies(request) + else: + raise ValueError("Unknown operation requested") + if not response.success: + raise ValueError( + f"Operation of type '{type_bool_op}' failed: {err_bool_op}.\n" + f"Involving bodies:{self}, {other_bodies}" + ) + + if not keep_other: + for b in other_bodies: + b.parent_component.delete_body(b) + + parent_design._update_design_inplace() @protect_grpc @reset_tessellation_cache diff --git a/src/ansys/geometry/core/designer/component.py b/src/ansys/geometry/core/designer/component.py index b1b0aafb34..ad53f3967e 100644 --- a/src/ansys/geometry/core/designer/component.py +++ b/src/ansys/geometry/core/designer/component.py @@ -37,6 +37,7 @@ CreateExtrudedBodyRequest, CreatePlanarBodyRequest, CreateSphereBodyRequest, + CreateSurfaceBodyFromTrimmedCurvesRequest, CreateSurfaceBodyRequest, CreateSweepingChainRequest, CreateSweepingProfileRequest, @@ -251,6 +252,11 @@ def id(self) -> str: """ID of the component.""" return self._id + @property + def _grpc_id(self) -> EntityIdentifier: + """ID of the component in gRPC format.""" + return EntityIdentifier(id=self.id) + @property def name(self) -> str: """Name of the component.""" @@ -993,6 +999,42 @@ def create_body_from_surface(self, name: str, trimmed_surface: TrimmedSurface) - self._clear_cached_bodies() return Body(response.id, response.name, self, tb) + @protect_grpc + @min_backend_version(25, 2, 0) + def create_surface_from_trimmed_curves( + self, name: str, trimmed_curves: list[TrimmedCurve] + ) -> Body: + """Create a surface body from a list of trimmed curves all lying on the same plane. + + Parameters + ---------- + name : str + User-defined label for the new surface body. + trimmed_curves : list[TrimmedCurve] + Curves to define the plane and body. + + Returns + ------- + Body + Surface body. + """ + curves = [trimmed_curve_to_grpc_trimmed_curve(curve) for curve in trimmed_curves] + request = CreateSurfaceBodyFromTrimmedCurvesRequest( + name=name, + parent=self.id, + trimmed_curves=curves, + ) + + self._grpc_client.log.debug( + f"Creating surface body from trimmed curves provided on {self.id}. Creating body..." + ) + response = self._bodies_stub.CreateSurfaceBodyFromTrimmedCurves(request) + + tb = MasterBody(response.master_id, name, self._grpc_client, is_surface=response.is_surface) + self._master_component.part.bodies.append(tb) + self._clear_cached_bodies() + return Body(response.id, response.name, self, tb) + @check_input_types @ensure_design_is_active def create_coordinate_system(self, name: str, frame: Frame) -> CoordinateSystem: @@ -1663,7 +1705,7 @@ def build_parent_tree(comp: Component, parent_tree: str = "") -> str: body_names = [body.name for body in self.bodies] # Add the bodies to the lines (with indentation) - lines.extend([f"|{'-' * (indent-1)}(body) {name}" for name in body_names]) + lines.extend([f"|{'-' * (indent - 1)}(body) {name}" for name in body_names]) # Print the beams if consider_beams: @@ -1680,7 +1722,7 @@ def build_parent_tree(comp: Component, parent_tree: str = "") -> str: beam_names = [beam.id for beam in self.beams if beam.is_alive] # Add the bodies to the lines (with indentation) - lines.extend([f"|{'-' * (indent-1)}(beam) {name}" for name in beam_names]) + lines.extend([f"|{'-' * (indent - 1)}(beam) {name}" for name in beam_names]) # Print the nested components if consider_comps: @@ -1708,13 +1750,13 @@ def build_parent_tree(comp: Component, parent_tree: str = "") -> str: ) # Add indentation to the subcomponent lines - lines.append(f"|{'-' * (indent-1)}(comp) {comp.name}") + lines.append(f"|{'-' * (indent - 1)}(comp) {comp.name}") # Determine the prefix for the subcomponent lines and add them - prefix = f"{' ' * indent}" if idx == (n_comps - 1) else f":{' ' * (indent-1)}" + prefix = f"{' ' * indent}" if idx == (n_comps - 1) else f":{' ' * (indent - 1)}" lines.extend([f"{prefix}{line}" for line in subcomp[1:]]) else: - lines.extend([f"|{'-' * (indent-1)}(comp) {comp.name}" for comp in comps]) + lines.extend([f"|{'-' * (indent - 1)}(comp) {comp.name}" for comp in comps]) return lines if return_list else print("\n".join(lines)) diff --git a/src/ansys/geometry/core/designer/design.py b/src/ansys/geometry/core/designer/design.py index 8afcf4ba16..32f16b6312 100644 --- a/src/ansys/geometry/core/designer/design.py +++ b/src/ansys/geometry/core/designer/design.py @@ -31,8 +31,15 @@ from pint import Quantity, UndefinedUnitError from ansys.api.dbu.v0.dbumodels_pb2 import EntityIdentifier, PartExportFormat -from ansys.api.dbu.v0.designs_pb2 import InsertRequest, NewRequest, SaveAsRequest +from ansys.api.dbu.v0.designs_pb2 import ( + DownloadExportFileRequest, + InsertRequest, + NewRequest, + SaveAsRequest, +) from ansys.api.dbu.v0.designs_pb2_grpc import DesignsStub +from ansys.api.dbu.v0.drivingdimensions_pb2 import GetAllRequest, UpdateRequest +from ansys.api.dbu.v0.drivingdimensions_pb2_grpc import DrivingDimensionsStub from ansys.api.geometry.v0.commands_pb2 import ( AssignMidSurfaceOffsetTypeRequest, AssignMidSurfaceThicknessRequest, @@ -74,6 +81,7 @@ from ansys.geometry.core.misc.checks import ensure_design_is_active, min_backend_version from ansys.geometry.core.misc.measurements import DEFAULT_UNITS, Distance from ansys.geometry.core.modeler import Modeler +from ansys.geometry.core.parameters.parameter import Parameter, ParameterUpdateStatus from ansys.geometry.core.typing import RealSequence @@ -81,13 +89,15 @@ class DesignFileFormat(Enum): """Provides supported file formats that can be downloaded for designs.""" - SCDOCX = "SCDOCX", None + SCDOCX = "SCDOCX", PartExportFormat.PARTEXPORTFORMAT_SCDOCX PARASOLID_TEXT = "PARASOLID_TEXT", PartExportFormat.PARTEXPORTFORMAT_PARASOLID_TEXT PARASOLID_BIN = "PARASOLID_BIN", PartExportFormat.PARTEXPORTFORMAT_PARASOLID_BINARY FMD = "FMD", PartExportFormat.PARTEXPORTFORMAT_FMD STEP = "STEP", PartExportFormat.PARTEXPORTFORMAT_STEP IGES = "IGES", PartExportFormat.PARTEXPORTFORMAT_IGES PMDB = "PMDB", PartExportFormat.PARTEXPORTFORMAT_PMDB + STRIDE = "STRIDE", PartExportFormat.PARTEXPORTFORMAT_STRIDE + DISCO = "DISCO", PartExportFormat.PARTEXPORTFORMAT_DISCO INVALID = "INVALID", None @@ -125,6 +135,7 @@ def __init__(self, name: str, modeler: Modeler, read_existing_design: bool = Fal self._materials_stub = MaterialsStub(self._grpc_client.channel) self._named_selections_stub = NamedSelectionsStub(self._grpc_client.channel) self._parts_stub = PartsStub(self._grpc_client.channel) + self._parameters_stub = DrivingDimensionsStub(self._grpc_client.channel) # Initialize needed instance variables self._materials = [] @@ -166,6 +177,11 @@ def beam_profiles(self) -> list[BeamProfile]: """List of beam profile available for the design.""" return list(self._beam_profiles.values()) + @property + def parameters(self) -> list[Parameter]: + """List of parameters available for the design.""" + return self.get_all_parameters() + @property def is_active(self) -> bool: """Whether the design is currently active.""" @@ -285,6 +301,38 @@ def download( # Create the parent directory file_location.parent.mkdir(parents=True, exist_ok=True) + # Process response + self._grpc_client.log.debug(f"Requesting design download in {format.value[0]} format.") + if self._modeler.client.backend_version < (25, 2, 0): + received_bytes = self.__export_and_download_legacy(format=format) + else: + received_bytes = self.__export_and_download(format=format) + + # Write to file + file_location.write_bytes(received_bytes) + self._grpc_client.log.debug(f"Design downloaded at location {file_location}.") + + def __export_and_download_legacy( + self, + format: DesignFileFormat = DesignFileFormat.SCDOCX, + ) -> bytes: + """Export and download the design from the server. + + Notes + ----- + This is a legacy method, which is used in versions + up to Ansys 25.1.1 products. + + Parameters + ---------- + format : DesignFileFormat, default: DesignFileFormat.SCDOCX + Format for the file to save to. + + Returns + ------- + bytes + The raw data from the exported and downloaded file. + """ # Process response self._grpc_client.log.debug(f"Requesting design download in {format.value[0]} format.") received_bytes = bytes() @@ -307,14 +355,50 @@ def download( ) return - # Write to file - downloaded_file = Path(file_location).open(mode="wb") - downloaded_file.write(received_bytes) - downloaded_file.close() + return received_bytes - self._grpc_client.log.debug( - f"Design is successfully downloaded at location {file_location}." - ) + def __export_and_download( + self, + format: DesignFileFormat = DesignFileFormat.SCDOCX, + ) -> bytes: + """Export and download the design from the server. + + Parameters + ---------- + format : DesignFileFormat, default: DesignFileFormat.SCDOCX + Format for the file to save to. + + Returns + ------- + bytes + The raw data from the exported and downloaded file. + """ + # Process response + self._grpc_client.log.debug(f"Requesting design download in {format.value[0]} format.") + received_bytes = bytes() + + if format in [ + DesignFileFormat.PARASOLID_TEXT, + DesignFileFormat.PARASOLID_BIN, + DesignFileFormat.FMD, + DesignFileFormat.STEP, + DesignFileFormat.IGES, + DesignFileFormat.PMDB, + DesignFileFormat.DISCO, + DesignFileFormat.SCDOCX, + DesignFileFormat.STRIDE, + ]: + response = self._design_stub.DownloadExportFile( + DownloadExportFileRequest(format=format.value[1]) + ) + received_bytes += response.data + else: + self._grpc_client.log.warning( + f"{format.value[0]} format requested is not supported. Ignoring download request." + ) + return + + return received_bytes def __build_export_file_location(self, location: Path | str | None, ext: str) -> Path: """Build the file location for export functions. @@ -357,6 +441,52 @@ def export_to_scdocx(self, location: Path | str | None = None) -> Path: # Return the file location return file_location + def export_to_disco(self, location: Path | str | None = None) -> Path: + """Export the design to an dsco file. + + Parameters + ---------- + location : ~pathlib.Path | str, optional + Location on disk to save the file to. If None, the file will be saved + in the current working directory. + + Returns + ------- + ~pathlib.Path + The path to the saved file. + """ + # Define the file location + file_location = self.__build_export_file_location(location, "dsco") + + # Export the design to an dsco file + self.download(file_location, DesignFileFormat.DISCO) + + # Return the file location + return file_location + + def export_to_stride(self, location: Path | str | None = None) -> Path: + """Export the design to an stride file. + + Parameters + ---------- + location : ~pathlib.Path | str, optional + Location on disk to save the file to. If None, the file will be saved + in the current working directory. + + Returns + ------- + ~pathlib.Path + The path to the saved file. + """ + # Define the file location + file_location = self.__build_export_file_location(location, "stride") + + # Export the design to an stride file + self.download(file_location, DesignFileFormat.STRIDE) + + # Return the file location + return file_location + def export_to_parasolid_text(self, location: Path | str | None = None) -> Path: """Export the design to a Parasolid text file. @@ -372,7 +502,11 @@ def export_to_parasolid_text(self, location: Path | str | None = None) -> Path: The path to the saved file. """ # Determine the extension based on the backend type - ext = "x_t" if self._grpc_client.backend_type == BackendType.LINUX_SERVICE else "xmt_txt" + ext = ( + "x_t" + if self._grpc_client.backend_type in (BackendType.LINUX_SERVICE, BackendType.CORE_LINUX) + else "xmt_txt" + ) # Define the file location file_location = self.__build_export_file_location(location, ext) @@ -398,7 +532,11 @@ def export_to_parasolid_bin(self, location: Path | str | None = None) -> Path: The path to the saved file. """ # Determine the extension based on the backend type - ext = "x_b" if self._grpc_client.backend_type == BackendType.LINUX_SERVICE else "xmt_bin" + ext = ( + "x_b" + if self._grpc_client.backend_type in (BackendType.LINUX_SERVICE, BackendType.CORE_LINUX) + else "xmt_bin" + ) # Define the file location file_location = self.__build_export_file_location(location, ext) @@ -679,6 +817,45 @@ def add_beam_circular_profile( return self._beam_profiles[profile.name] + @protect_grpc + @min_backend_version(25, 1, 0) + def get_all_parameters(self) -> list[Parameter]: + """Get parameters for the design. + + Returns + ------- + list[Parameter] + List of parameters for the design. + """ + response = self._parameters_stub.GetAll(GetAllRequest()) + return [Parameter._from_proto(dimension) for dimension in response.driving_dimensions] + + @protect_grpc + @check_input_types + @min_backend_version(25, 1, 0) + def set_parameter(self, dimension: Parameter) -> ParameterUpdateStatus: + """Set or update a parameter of the design. + + Parameters + ---------- + dimension : Parameter + Parameter to set. + + Returns + ------- + ParameterUpdateStatus + Status of the update operation. + """ + request = UpdateRequest(driving_dimension=Parameter._to_proto(dimension)) + response = self._parameters_stub.UpdateParameter(request) + status = response.status + + # Update the design in place. This method is computationally expensive, + # consider finding a more efficient approach. + self._update_design_inplace() + + return ParameterUpdateStatus._from_update_status(status) + @protect_grpc @check_input_types @ensure_design_is_active @@ -806,27 +983,7 @@ def insert_file(self, file_location: Path | str) -> Component: self._design_stub.Insert(InsertRequest(filepath=filepath_server)) self._grpc_client.log.debug(f"File {file_location} successfully inserted into design.") - # Get a temporal design object to update the current one - tmp_design = Design("", self._modeler, read_existing_design=True) - - # Update the reference to the design - for component in tmp_design.components: - component._parent_component = self - - # Update the design's components - add the new one - # - # If the list is empty, add the components from the new design - if not self._components: - self._components.extend(tmp_design.components) - else: - # Insert operation adds the inserted file as a component to the design. - for tmp_component in tmp_design.components: - # Otherwise, check which is the new component added - for component in self._components: - if component.id == tmp_component.id: - break - # If not equal, add the component - since it has not been found - self._components.append(tmp_component) + self._update_design_inplace() self._grpc_client.log.debug(f"Design {self.name} is successfully updated.") @@ -1037,7 +1194,7 @@ def _update_design_inplace(self) -> None: # https://github.com/ansys/pyansys-geometry/issues/1319 # self._components = [] - self._bodies = [] + self._clear_cached_bodies() self._materials = [] self._named_selections = {} self._coordinate_systems = {} diff --git a/src/ansys/geometry/core/designer/edge.py b/src/ansys/geometry/core/designer/edge.py index eb38b7c117..3b67b9c06d 100644 --- a/src/ansys/geometry/core/designer/edge.py +++ b/src/ansys/geometry/core/designer/edge.py @@ -100,6 +100,11 @@ def _grpc_id(self) -> EntityIdentifier: """Entity ID of this edge on the server side.""" return EntityIdentifier(id=self._id) + @property + def body(self) -> "Body": + """Body of the edge.""" + return self._body + @property def is_reversed(self) -> bool: """Flag indicating if the edge is reversed.""" diff --git a/src/ansys/geometry/core/designer/face.py b/src/ansys/geometry/core/designer/face.py index 842e03af59..b437a49123 100644 --- a/src/ansys/geometry/core/designer/face.py +++ b/src/ansys/geometry/core/designer/face.py @@ -27,6 +27,8 @@ from pint import Quantity from ansys.api.dbu.v0.dbumodels_pb2 import EntityIdentifier +from ansys.api.geometry.v0.commands_pb2 import FaceOffsetRequest +from ansys.api.geometry.v0.commands_pb2_grpc import CommandsStub from ansys.api.geometry.v0.edges_pb2_grpc import EdgesStub from ansys.api.geometry.v0.faces_pb2 import ( CreateIsoParamCurvesRequest, @@ -176,11 +178,10 @@ def __init__( self._grpc_client = grpc_client self._faces_stub = FacesStub(grpc_client.channel) self._edges_stub = EdgesStub(grpc_client.channel) + self._commands_stub = CommandsStub(grpc_client.channel) self._is_reversed = is_reversed self._shape = None - self._grpc_client.log.debug("Requesting surface properties from server.") - @property def id(self) -> str: """Face ID.""" @@ -472,3 +473,35 @@ def create_isoparametric_curves( ) return trimmed_curves + + @protect_grpc + @min_backend_version(25, 2, 0) + def setup_offset_relationship( + self, other_face: "Face", set_baselines: bool = False, process_adjacent_faces: bool = False + ) -> bool: + """Create an offset relationship between two faces. + + Parameters + ---------- + other_face : Face + The face to setup an offset relationship with. + set_baselines : bool, default: False + Automatically set baseline faces. + process_adjacent_faces : bool, default: False + Look for relationships of the same offset on adjacent faces. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + result = self._commands_stub.FaceOffset( + FaceOffsetRequest( + face1=self._grpc_id, + face2=other_face._grpc_id, + set_baselines=set_baselines, + process_adjacent_faces=process_adjacent_faces, + ) + ) + + return result.success diff --git a/src/ansys/geometry/core/designer/geometry_commands.py b/src/ansys/geometry/core/designer/geometry_commands.py new file mode 100644 index 0000000000..ef550cd162 --- /dev/null +++ b/src/ansys/geometry/core/designer/geometry_commands.py @@ -0,0 +1,1140 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Provides tools for pulling geometry.""" + +from enum import Enum, unique +from typing import TYPE_CHECKING, Union + +from ansys.api.geometry.v0.commands_pb2 import ( + ChamferRequest, + CreateCircularPatternRequest, + CreateFillPatternRequest, + CreateLinearPatternRequest, + ExtrudeEdgesRequest, + ExtrudeEdgesUpToRequest, + ExtrudeFacesRequest, + ExtrudeFacesUpToRequest, + FilletRequest, + FullFilletRequest, + ModifyLinearPatternRequest, + PatternRequest, + RenameObjectRequest, + ReplaceFaceRequest, + RevolveFacesByHelixRequest, + RevolveFacesRequest, + RevolveFacesUpToRequest, + SplitBodyRequest, +) +from ansys.api.geometry.v0.commands_pb2_grpc import CommandsStub +from ansys.geometry.core.connection.client import GrpcClient +from ansys.geometry.core.connection.conversions import ( + line_to_grpc_line, + plane_to_grpc_plane, + point3d_to_grpc_point, + unit_vector_to_grpc_direction, +) +from ansys.geometry.core.errors import protect_grpc +from ansys.geometry.core.math.plane import Plane +from ansys.geometry.core.math.point import Point3D +from ansys.geometry.core.math.vector import UnitVector3D +from ansys.geometry.core.misc.auxiliary import ( + get_bodies_from_ids, + get_design_from_body, + get_design_from_edge, + get_design_from_face, +) +from ansys.geometry.core.misc.checks import ( + check_is_float_int, + check_type, + check_type_all_elements_in_iterable, + min_backend_version, +) +from ansys.geometry.core.shapes.curves.line import Line +from ansys.geometry.core.typing import Real + +if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.designer.body import Body + from ansys.geometry.core.designer.component import Component + from ansys.geometry.core.designer.edge import Edge + from ansys.geometry.core.designer.face import Face + + +@unique +class ExtrudeType(Enum): + """Provides values for extrusion types.""" + + NONE = 0 + ADD = 1 + CUT = 2 + FORCE_ADD = 3 + FORCE_CUT = 4 + FORCE_INDEPENDENT = 5 + FORCE_NEW_SURFACE = 6 + + +@unique +class OffsetMode(Enum): + """Provides values for offset modes during extrusions.""" + + IGNORE_RELATIONSHIPS = 0 + MOVE_FACES_TOGETHER = 1 + MOVE_FACES_APART = 2 + + +@unique +class FillPatternType(Enum): + """Provides values for types of fill patterns.""" + + GRID = 0 + OFFSET = 1 + SKEWED = 2 + + +class GeometryCommands: + """Provides geometry commands for PyAnsys Geometry. + + Parameters + ---------- + grpc_client : GrpcClient + gRPC client to use for the geometry commands. + """ + + @protect_grpc + def __init__(self, grpc_client: GrpcClient): + """Initialize an instance of the ``GeometryCommands`` class.""" + self._grpc_client = grpc_client + self._commands_stub = CommandsStub(self._grpc_client.channel) + + @protect_grpc + @min_backend_version(25, 2, 0) + def chamfer( + self, + selection: Union["Edge", list["Edge"], "Face", list["Face"]], + distance: Real, + ) -> bool: + """Create a chamfer on an edge or adjust the chamfer of a face. + + Parameters + ---------- + selection : Edge | list[Edge] | Face | list[Face] + One or more edges or faces to act on. + distance : Real + Chamfer distance. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.edge import Edge + from ansys.geometry.core.designer.face import Face + + selection: list[Edge | Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, (Edge, Face)) + check_is_float_int(distance, "distance") + + for ef in selection: + ef.body._reset_tessellation_cache() + + result = self._commands_stub.Chamfer( + ChamferRequest(ids=[ef._grpc_id for ef in selection], distance=distance) + ) + + return result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def fillet( + self, selection: Union["Edge", list["Edge"], "Face", list["Face"]], radius: Real + ) -> bool: + """Create a fillet on an edge or adjust the fillet of a face. + + Parameters + ---------- + selection : Edge | list[Edge] | Face | list[Face] + One or more edges or faces to act on. + radius : Real + Fillet radius. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.edge import Edge + from ansys.geometry.core.designer.face import Face + + selection: list[Edge | Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, (Edge, Face)) + check_is_float_int(radius, "radius") + + for ef in selection: + ef.body._reset_tessellation_cache() + + result = self._commands_stub.Fillet( + FilletRequest(ids=[ef._grpc_id for ef in selection], radius=radius) + ) + + return result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def full_fillet(self, faces: list["Face"]) -> bool: + """Create a full fillet betweens a collection of faces. + + Parameters + ---------- + faces : list[Face] + Faces to round. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + check_type_all_elements_in_iterable(faces, Face) + + for face in faces: + face.body._reset_tessellation_cache() + + result = self._commands_stub.FullFillet( + FullFilletRequest(faces=[face._grpc_id for face in faces]) + ) + + return result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def extrude_faces( + self, + faces: Union["Face", list["Face"]], + distance: Real, + direction: UnitVector3D = None, + extrude_type: ExtrudeType = ExtrudeType.ADD, + offset_mode: OffsetMode = OffsetMode.MOVE_FACES_TOGETHER, + pull_symmetric: bool = False, + copy: bool = False, + force_do_as_extrude: bool = False, + ) -> list["Body"]: + """Extrude a selection of faces. + + Parameters + ---------- + faces : Face | list[Face] + Faces to extrude. + distance : Real + Distance to extrude. + direction : UnitVector3D, default: None + Direction of extrusion. If no direction is provided, it will be inferred. + extrude_type : ExtrudeType, default: ExtrudeType.ADD + Type of extrusion to be performed. + offset_mode : OffsetMode, default: OffsetMode.MOVE_FACES_TOGETHER + Mode of how to handle offset relationships. + pull_symmetric : bool, default: False + Pull symmetrically on both sides if ``True``. + copy : bool, default: False + Copy the face and move it instead of extruding the original face if ``True``. + force_do_as_extrude : bool, default: False + Forces to do as an extrusion if ``True``, if ``False`` allows extrusion by offset. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.face import Face + + faces: list[Face] = faces if isinstance(faces, list) else [faces] + check_type_all_elements_in_iterable(faces, Face) + check_is_float_int(distance, "distance") + + for face in faces: + face.body._reset_tessellation_cache() + + result = self._commands_stub.ExtrudeFaces( + ExtrudeFacesRequest( + faces=[face._grpc_id for face in faces], + distance=distance, + direction=None if direction is None else unit_vector_to_grpc_direction(direction), + extrude_type=extrude_type.value, + pull_symmetric=pull_symmetric, + offset_mode=offset_mode.value, + copy=copy, + force_do_as_extrude=force_do_as_extrude, + ) + ) + + design = get_design_from_face(faces[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to extrude faces.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def extrude_faces_up_to( + self, + faces: Union["Face", list["Face"]], + up_to_selection: Union["Face", "Edge", "Body"], + seed_point: Point3D, + direction: UnitVector3D, + extrude_type: ExtrudeType = ExtrudeType.ADD, + offset_mode: OffsetMode = OffsetMode.MOVE_FACES_TOGETHER, + pull_symmetric: bool = False, + copy: bool = False, + force_do_as_extrude: bool = False, + ) -> list["Body"]: + """Extrude a selection of faces up to another object. + + Parameters + ---------- + faces : Face | list[Face] + Faces to extrude. + up_to_selection : Face | Edge | Body + The object to pull the faces up to. + seed_point : Point3D + Origin to define the extrusion. + direction : UnitVector3D, default: None + Direction of extrusion. If no direction is provided, it will be inferred. + extrude_type : ExtrudeType, default: ExtrudeType.ADD + Type of extrusion to be performed. + offset_mode : OffsetMode, default: OffsetMode.MOVE_FACES_TOGETHER + Mode of how to handle offset relationships. + pull_symmetric : bool, default: False + Pull symmetrically on both sides if ``True``. + copy : bool, default: False + Copy the face and move it instead of extruding the original face if ``True``. + force_do_as_extrude : bool, default: False + Forces to do as an extrusion if ``True``, if ``False`` allows extrusion by offset. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.face import Face + + faces: list[Face] = faces if isinstance(faces, list) else [faces] + check_type_all_elements_in_iterable(faces, Face) + + for face in faces: + face.body._reset_tessellation_cache() + + result = self._commands_stub.ExtrudeFacesUpTo( + ExtrudeFacesUpToRequest( + faces=[face._grpc_id for face in faces], + up_to_selection=up_to_selection._grpc_id, + seed_point=point3d_to_grpc_point(seed_point), + direction=unit_vector_to_grpc_direction(direction), + extrude_type=extrude_type.value, + pull_symmetric=pull_symmetric, + offset_mode=offset_mode.value, + copy=copy, + force_do_as_extrude=force_do_as_extrude, + ) + ) + + design = get_design_from_face(faces[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to extrude faces.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def extrude_edges( + self, + edges: Union["Edge", list["Edge"]], + distance: Real, + from_face: "Face" = None, + from_point: Point3D = None, + direction: UnitVector3D = None, + extrude_type: ExtrudeType = ExtrudeType.ADD, + pull_symmetric: bool = False, + copy: bool = False, + natural_extension: bool = False, + ) -> list["Body"]: + """Extrude a selection of edges. Provide either a face or a direction and point. + + Parameters + ---------- + edges : Edge | list[Edge] + Edges to extrude. + distance : Real + Distance to extrude. + from_face : Face, default: None + Face to pull normal from. + from_point : Point3D, default: None + Point to pull from. Must be used with ``direction``. + direction : UnitVector3D, default: None + Direction to pull. Must be used with ``from_point``. + extrude_type : ExtrudeType, default: ExtrudeType.ADD + Type of extrusion to be performed. + pull_symmetric : bool, default: False + Pull symmetrically on both sides if ``True``. + copy : bool, default: False + Copy the edge and move it instead of extruding the original edge if ``True``. + natural_extension : bool, default: False + Surfaces will extend in a natural or linear shape after exceeding its original range. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.edge import Edge + + edges: list[Edge] = edges if isinstance(edges, list) else [edges] + check_type_all_elements_in_iterable(edges, Edge) + check_is_float_int(distance, "distance") + if from_face is None and None in (from_point, direction): + raise ValueError( + "To extrude edges, either a face or a direction and point must be provided." + ) + + for edge in edges: + edge.body._reset_tessellation_cache() + + result = self._commands_stub.ExtrudeEdges( + ExtrudeEdgesRequest( + edges=[edge._grpc_id for edge in edges], + distance=distance, + face=from_face._grpc_id, + point=None if from_point is None else point3d_to_grpc_point(from_point), + direction=None if direction is None else unit_vector_to_grpc_direction(direction), + extrude_type=extrude_type.value, + pull_symmetric=pull_symmetric, + copy=copy, + natural_extension=natural_extension, + ) + ) + + design = get_design_from_edge(edges[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to extrude edges.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def extrude_edges_up_to( + self, + edges: Union["Edge", list["Edge"]], + up_to_selection: Union["Face", "Edge", "Body"], + seed_point: Point3D, + direction: UnitVector3D, + extrude_type: ExtrudeType = ExtrudeType.ADD, + ) -> list["Body"]: + """Extrude a selection of edges up to another object. + + Parameters + ---------- + edges : Edge | list[Edge] + Edges to extrude. + up_to_selection : Face, default: None + The object to pull the faces up to. + seed_point : Point3D + Origin to define the extrusion. + direction : UnitVector3D, default: None + Direction of extrusion. + extrude_type : ExtrudeType, default: ExtrudeType.ADD + Type of extrusion to be performed. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.edge import Edge + + edges: list[Edge] = edges if isinstance(edges, list) else [edges] + check_type_all_elements_in_iterable(edges, Edge) + + for edge in edges: + edge.body._reset_tessellation_cache() + + result = self._commands_stub.ExtrudeEdgesUpTo( + ExtrudeEdgesUpToRequest( + edges=[edge._grpc_id for edge in edges], + up_to_selection=up_to_selection._grpc_id, + seed_point=point3d_to_grpc_point(seed_point), + direction=unit_vector_to_grpc_direction(direction), + extrude_type=extrude_type.value, + ) + ) + + design = get_design_from_edge(edges[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to extrude edges.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def rename_object( + self, + selection: Union[list["Body"], list["Component"], list["Face"], list["Edge"]], + name: str, + ) -> bool: + """Rename an object. + + Parameters + ---------- + selection : list[Body] | list[Component] | list[Face] | list[Edge] + Selection of the object to rename. + name : str + New name for the object. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + result = self._commands_stub.RenameObject( + RenameObjectRequest(selection=[object._grpc_id for object in selection], name=name) + ) + return result.success + + def create_linear_pattern( + self, + selection: Union["Face", list["Face"]], + linear_direction: Union["Edge", "Face"], + count_x: int, + pitch_x: Real, + two_dimensional: bool = False, + count_y: int = None, + pitch_y: Real = None, + ) -> bool: + """Create a linear pattern. The pattern can be one or two dimensions. + + Parameters + ---------- + selection : Face | list[Face] + Faces to create the pattern out of. + linear_direction : Edge | Face + Direction of the linear pattern, determined by the direction of an edge or face normal. + count_x : int + How many times the pattern repeats in the x direction. + pitch_x : Real + The spacing between each pattern member in the x direction. + two_dimensional : bool, default: False + If ``True``, create a pattern in the x and y direction. + count_y : int, default: None + How many times the pattern repeats in the y direction. + pitch_y : Real, default: None + The spacing between each pattern member in the y direction. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + if two_dimensional and None in (count_y, pitch_y): + raise ValueError( + "If the pattern is two dimensional, count_y and pitch_y must be provided." + ) + if not two_dimensional and None not in (count_y, pitch_y): + raise ValueError( + ( + "You provided count_y and pitch_y. Ensure two_dimensional is True if a " + "two-dimensional pattern is desired." + ) + ) + + result = self._commands_stub.CreateLinearPattern( + CreateLinearPatternRequest( + selection=[object._grpc_id for object in selection], + linear_direction=linear_direction._grpc_id, + count_x=count_x, + pitch_x=pitch_x, + two_dimensional=two_dimensional, + count_y=count_y, + pitch_y=pitch_y, + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def modify_linear_pattern( + self, + selection: Union["Face", list["Face"]], + count_x: int = 0, + pitch_x: Real = 0.0, + count_y: int = 0, + pitch_y: Real = 0.0, + new_seed_index: int = 0, + old_seed_index: int = 0, + ) -> bool: + """Modify a linear pattern. Leave an argument at 0 for it to remain unchanged. + + Parameters + ---------- + selection : Face | list[Face] + Faces that belong to the pattern. + count_x : int, default: 0 + How many times the pattern repeats in the x direction. + pitch_x : Real, default: 0.0 + The spacing between each pattern member in the x direction. + count_y : int, default: 0 + How many times the pattern repeats in the y direction. + pitch_y : Real, default: 0.0 + The spacing between each pattern member in the y direction. + new_seed_index : int, default: 0 + The new seed index of the member. + old_seed_index : int, default: 0 + The old seed index of the member. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.ModifyLinearPattern( + ModifyLinearPatternRequest( + selection=[object._grpc_id for object in selection], + count_x=count_x, + pitch_x=pitch_x, + count_y=count_y, + pitch_y=pitch_y, + new_seed_index=new_seed_index, + old_seed_index=old_seed_index, + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def create_circular_pattern( + self, + selection: Union["Face", list["Face"]], + circular_axis: "Edge", + circular_count: int, + circular_angle: Real, + two_dimensional: bool = False, + linear_count: int = None, + linear_pitch: Real = None, + radial_direction: UnitVector3D = None, + ) -> bool: + """Create a circular pattern. The pattern can be one or two dimensions. + + Parameters + ---------- + selection : Face | list[Face] + Faces to create the pattern out of. + circular_axis : Edge + The axis of the circular pattern, determined by the direction of an edge. + circular_count : int + How many members are in the circular pattern. + circular_angle : Real + The angular range of the pattern. + two_dimensional : bool, default: False + If ``True``, create a two-dimensional pattern. + linear_count : int, default: None + How many times the circular pattern repeats along the radial lines for a + two-dimensional pattern. + linear_pitch : Real, default: None + The spacing along the radial lines for a two-dimensional pattern. + radial_direction : UnitVector3D, default: None + The direction from the center out for a two-dimensional pattern. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + if two_dimensional and None in (linear_count, linear_pitch): + raise ValueError( + "If the pattern is two-dimensional, linear_count and linear_pitch must be provided." + ) + if not two_dimensional and None not in ( + linear_count, + linear_pitch, + ): + raise ValueError( + ( + "You provided linear_count and linear_pitch. Ensure two_dimensional is True if " + "a two-dimensional pattern is desired." + ) + ) + + result = self._commands_stub.CreateCircularPattern( + CreateCircularPatternRequest( + selection=[object._grpc_id for object in selection], + circular_axis=circular_axis._grpc_id, + circular_count=circular_count, + circular_angle=circular_angle, + two_dimensional=two_dimensional, + linear_count=linear_count, + linear_pitch=linear_pitch, + radial_direction=None + if radial_direction is None + else unit_vector_to_grpc_direction(radial_direction), + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def create_fill_pattern( + self, + selection: Union["Face", list["Face"]], + linear_direction: Union["Edge", "Face"], + fill_pattern_type: FillPatternType, + margin: Real, + x_spacing: Real, + y_spacing: Real, + row_x_offset: Real = 0, + row_y_offset: Real = 0, + column_x_offset: Real = 0, + column_y_offset: Real = 0, + ) -> bool: + """Create a fill pattern. + + Parameters + ---------- + selection : Face | list[Face] + Faces to create the pattern out of. + linear_direction : Edge + Direction of the linear pattern, determined by the direction of an edge. + fill_pattern_type : FillPatternType + The type of fill pattern. + margin : Real + Margin defining the border of the fill pattern. + x_spacing : Real + Spacing between the pattern members in the x direction. + y_spacing : Real + Spacing between the pattern members in the x direction. + row_x_offset : Real, default: 0 + Offset for the rows in the x direction. Only used with ``FillPattern.SKEWED``. + row_y_offset : Real, default: 0 + Offset for the rows in the y direction. Only used with ``FillPattern.SKEWED``. + column_x_offset : Real, default: 0 + Offset for the columns in the x direction. Only used with ``FillPattern.SKEWED``. + column_y_offset : Real, default: 0 + Offset for the columns in the y direction. Only used with ``FillPattern.SKEWED``. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.CreateFillPattern( + CreateFillPatternRequest( + selection=[object._grpc_id for object in selection], + linear_direction=linear_direction._grpc_id, + fill_pattern_type=fill_pattern_type.value, + margin=margin, + x_spacing=x_spacing, + y_spacing=y_spacing, + row_x_offset=row_x_offset, + row_y_offset=row_y_offset, + column_x_offset=column_x_offset, + column_y_offset=column_y_offset, + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def update_fill_pattern( + self, + selection: Union["Face", list["Face"]], + ) -> bool: + """Update a fill pattern. + + When the face that a fill pattern exists upon changes in size, the + fill pattern can be updated to fill the new space. + + Parameters + ---------- + selection : Face | list[Face] + Face(s) that are part of a fill pattern. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.UpdateFillPattern( + PatternRequest( + selection=[object._grpc_id for object in selection], + ) + ) + + return result.result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def revolve_faces( + self, + selection: Union["Face", list["Face"]], + axis: Line, + angle: Real, + ) -> list["Body"]: + """Revolve face around an axis. + + Parameters + ---------- + selection : Face | list[Face] + Face(s) to revolve. + axis : Line + Axis of revolution. + angle : Real + Angular distance to revolve. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.RevolveFaces( + RevolveFacesRequest( + selection=[object._grpc_id for object in selection], + axis=line_to_grpc_line(axis), + angle=angle, + ) + ) + + design = get_design_from_face(selection[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to revolve faces.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def revolve_faces_up_to( + self, + selection: Union["Face", list["Face"]], + up_to: Union["Face", "Edge", "Body"], + axis: Line, + direction: UnitVector3D, + extrude_type: ExtrudeType = ExtrudeType.ADD, + ) -> list["Body"]: + """Revolve face around an axis up to a certain object. + + Parameters + ---------- + selection : Face | list[Face] + Face(s) to revolve. + up_to : Face | Edge | Body + Object to revolve the face up to. + axis : Line + Axis of revolution. + direction : UnitVector3D + Direction of extrusion. + extrude_type : ExtrudeType, default: ExtrudeType.ADD + Type of extrusion to be performed. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.RevolveFacesUpTo( + RevolveFacesUpToRequest( + selection=[object._grpc_id for object in selection], + up_to_selection=up_to._grpc_id, + axis=line_to_grpc_line(axis), + direction=unit_vector_to_grpc_direction(direction), + extrude_type=extrude_type.value, + ) + ) + + design = get_design_from_face(selection[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to revolve faces.") + return [] + + @protect_grpc + @min_backend_version(25, 2, 0) + def revolve_faces_by_helix( + self, + selection: Union["Face", list["Face"]], + axis: Line, + direction: UnitVector3D, + height: Real, + pitch: Real, + taper_angle: Real, + right_handed: bool, + both_sides: bool, + ) -> list["Body"]: + """Revolve face around an axis in a helix shape. + + Parameters + ---------- + selection : Face | list[Face] + Face(s) to revolve. + axis : Line + Axis of revolution. + direction : UnitVector3D + Direction of extrusion. + height : Real, + Height of the helix. + pitch : Real, + Pitch of the helix. + taper_angle : Real, + Tape angle of the helix. + right_handed : bool, + Right-handed helix if ``True``, left-handed if ``False``. + both_sides : bool, + Create on both sides if ``True``, one side if ``False``. + + Returns + ------- + list[Body] + Bodies created by the extrusion if any. + """ + from ansys.geometry.core.designer.face import Face + + selection: list[Face] = selection if isinstance(selection, list) else [selection] + check_type_all_elements_in_iterable(selection, Face) + + for object in selection: + object.body._reset_tessellation_cache() + + result = self._commands_stub.RevolveFacesByHelix( + RevolveFacesByHelixRequest( + selection=[object._grpc_id for object in selection], + axis=line_to_grpc_line(axis), + direction=unit_vector_to_grpc_direction(direction), + height=height, + pitch=pitch, + taper_angle=taper_angle, + right_handed=right_handed, + both_sides=both_sides, + ) + ) + + design = get_design_from_face(selection[0]) + + if result.success: + bodies_ids = [created_body.id for created_body in result.created_bodies] + design._update_design_inplace() + return get_bodies_from_ids(design, bodies_ids) + else: + self._grpc_client.log.info("Failed to revolve faces.") + return [] + + def replace_face( + self, + target_selection: Union["Face", list["Face"]], + replacement_selection: Union["Face", list["Face"]], + ) -> bool: + """Replace a face with another face. + + Parameters + ---------- + target_selection : Union[Face, list[Face]] + The face or faces to replace. + replacement_selection : Union[Face, list[Face]] + The face or faces to replace with. + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + target_selection: list["Face"] = ( + target_selection if isinstance(target_selection, list) else [target_selection] + ) + replacement_selection: list["Face"] = ( + replacement_selection + if isinstance(replacement_selection, list) + else [replacement_selection] + ) + + result = self._commands_stub.ReplaceFace( + ReplaceFaceRequest( + target_selection=[selection._grpc_id for selection in target_selection], + replacement_selection=[selection._grpc_id for selection in replacement_selection], + ) + ) + + return result.success + + @protect_grpc + @min_backend_version(25, 2, 0) + def split_body( + self, + bodies: list["Body"], + plane: Plane, + slicers: Union["Edge", list["Edge"], "Face", list["Face"]], + faces: list["Face"], + extendfaces: bool, + ) -> bool: + """Split bodies with a plane, slicers, or faces. + + Parameters + ---------- + bodies : list[Body] + Bodies to split + plane : Plane + Plane to split with + slicers : Edge | list[Edge] | Face | list[Face] + Slicers to split with + faces : list[Face] + Faces to split with + extendFaces : bool + Extend faces if split with faces + + Returns + ------- + bool + ``True`` when successful, ``False`` when failed. + """ + from ansys.geometry.core.designer.body import Body + from ansys.geometry.core.designer.edge import Edge + from ansys.geometry.core.designer.face import Face + + check_type_all_elements_in_iterable(bodies, Body) + + for body in bodies: + body._reset_tessellation_cache() + + plane_item = None + if plane is not None: + check_type(plane, Plane) + plane_item = plane_to_grpc_plane(plane) + + slicer_items = None + if slicers is not None: + slicers: list["Face", "Edge"] = slicers if isinstance(slicers, list) else [slicers] + check_type_all_elements_in_iterable(slicers, (Edge, Face)) + slicer_items = [slicer._grpc_id for slicer in slicers] + + face_items = None + if faces is not None: + faces: list["Face"] = faces if isinstance(faces, list) else [faces] + check_type_all_elements_in_iterable(faces, Face) + face_items = [face._grpc_id for face in faces] + + result = self._commands_stub.SplitBody( + SplitBodyRequest( + selection=[body._grpc_id for body in bodies], + split_by_plane=plane_item, + split_by_slicer=slicer_items, + split_by_faces=face_items, + extend_surfaces=extendfaces, + ) + ) + + if result.success: + design = get_design_from_body(bodies[0]) + design._update_design_inplace() + + return result.success diff --git a/src/ansys/geometry/core/math/matrix.py b/src/ansys/geometry/core/math/matrix.py index fc5b90d195..f0fdcf41f3 100644 --- a/src/ansys/geometry/core/math/matrix.py +++ b/src/ansys/geometry/core/math/matrix.py @@ -21,14 +21,17 @@ # SOFTWARE. """Provides matrix primitive representations.""" -from typing import Union +from typing import TYPE_CHECKING, Union from beartype import beartype as check_input_types import numpy as np -from ansys.geometry.core.misc.checks import check_ndarray_is_float_int +from ansys.geometry.core.misc.checks import check_ndarray_is_float_int, check_type from ansys.geometry.core.typing import Real, RealSequence +if TYPE_CHECKING: + from ansys.geometry.core.math.vector import Vector3D # For type hints + DEFAULT_MATRIX33 = np.identity(3) """Default value of the 3x3 identity matrix for the ``Matrix33`` class.""" @@ -129,3 +132,179 @@ def __new__(cls, input: np.ndarray | RealSequence | Matrix = DEFAULT_MATRIX44): raise ValueError("Matrix44 should only be a 2D array of shape (4,4).") return obj + + @classmethod + def create_translation(cls, translation: "Vector3D") -> "Matrix44": + """Create a matrix representing the specified translation. + + Parameters + ---------- + translation : Vector3D + The translation vector representing the translation. The components of the vector + should be in meters. + + Returns + ------- + Matrix44 + A 4x4 matrix representing the translation. + + Examples + -------- + >>> translation_vector = Vector3D(1.0, 2.0, 3.0) + >>> translation_matrix = Matrix44.create_translation(translation_vector) + >>> print(translation_matrix) + [[1. 0. 0. 1.] + [0. 1. 0. 2.] + [0. 0. 1. 3.] + [0. 0. 0. 1.]] + """ + from ansys.geometry.core.math.vector import Vector3D + + # Verify the input + check_type(translation, Vector3D) + + matrix = cls( + [ + [1, 0, 0, translation.x], + [0, 1, 0, translation.y], + [0, 0, 1, translation.z], + [0, 0, 0, 1], + ] + ) + return matrix + + def is_translation(self, including_identity: bool = False) -> bool: + """Check if the matrix represents a translation. + + This method checks if the matrix represents a translation transformation. + A translation matrix has the following form: + + [1 0 0 tx] + [0 1 0 ty] + [0 0 1 tz] + [0 0 0 1] + + Parameters + ---------- + including_identity : bool, optional + If ``True``, the method will return ``True`` for the identity matrix as well. + If ``False``, the method will return ``False`` for the identity matrix. + + Returns + ------- + bool + ``True`` if the matrix represents a translation, ``False`` otherwise. + + Examples + -------- + >>> matrix = Matrix44([[1, 0, 0, 5], [0, 1, 0, 3], [0, 0, 1, 2], [0, 0, 0, 1]]) + >>> matrix.is_translation() + True + >>> identity_matrix = Matrix44([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + >>> identity_matrix.is_translation() + False + >>> identity_matrix.is_translation(including_identity=True) + True + """ + if not ( + self.__is_close(self[0][0], 1) + and self.__is_close(self[0][1], 0) + and self.__is_close(self[0][2], 0) + ): + return False + if not ( + self.__is_close(self[1][0], 0) + and self.__is_close(self[1][1], 1) + and self.__is_close(self[1][2], 0) + ): + return False + if not ( + self.__is_close(self[2][0], 0) + and self.__is_close(self[2][1], 0) + and self.__is_close(self[2][2], 1) + ): + return False + if not self.__is_close(self[2][2], 1): + return False + + if ( + not including_identity + and self.__is_close(self[0][3], 0) + and self.__is_close(self[1][3], 0) + and self.__is_close(self[2][3], 0) + ): + return False + + return True + + def __is_close(self, a, b, tol=1e-9): + """Check if two values are close to each other within a tolerance.""" + return np.isclose(a, b, atol=tol) + + @classmethod + def create_rotation( + cls, direction_x: "Vector3D", direction_y: "Vector3D", direction_z: "Vector3D" = None + ) -> "Matrix44": + """Create a matrix representing the specified rotation. + + Parameters + ---------- + direction_x : Vector3D + The X direction vector. + direction_y : Vector3D + The Y direction vector. + direction_z : Vector3D, optional + The Z direction vector. If not provided, it will be calculated + as the cross product of direction_x and direction_y. + + Returns + ------- + Matrix44 + A 4x4 matrix representing the rotation. + + Examples + -------- + >>> direction_x = Vector3D(1.0, 0.0, 0.0) + >>> direction_y = Vector3D(0.0, 1.0, 0.0) + >>> rotation_matrix = Matrix44.create_rotation(direction_x, direction_y) + >>> print(rotation_matrix) + [[1. 0. 0. 0.] + [0. 1. 0. 0.] + [0. 0. 1. 0.] + [0. 0. 0. 1.]] + """ + from ansys.geometry.core.math.vector import Vector3D + + # Verify the inputs + check_type(direction_x, Vector3D) + check_type(direction_y, Vector3D) + if direction_z is not None: + check_type(direction_z, Vector3D) + + if not direction_x.is_perpendicular_to(direction_y): + raise ValueError("The provided direction vectors are not orthogonal.") + + # Normalize the vectors + direction_x = direction_x.normalize() + direction_y = direction_y.normalize() + + # Calculate the third direction vector if not provided + if direction_z is None: + direction_z = direction_x.cross(direction_y) + else: + if not ( + direction_x.is_perpendicular_to(direction_z) + and direction_y.is_perpendicular_to(direction_z) + ): + raise ValueError("The provided direction vectors are not orthogonal.") + direction_z = direction_z.normalize() + + matrix = cls( + [ + [direction_x.x, direction_y.x, direction_z.x, 0], + [direction_x.y, direction_y.y, direction_z.y, 0], + [direction_x.z, direction_y.z, direction_z.z, 0], + [0, 0, 0, 1], + ] + ) + return matrix diff --git a/src/ansys/geometry/core/misc/auxiliary.py b/src/ansys/geometry/core/misc/auxiliary.py index c88a809b76..7f9d7e42c4 100644 --- a/src/ansys/geometry/core/misc/auxiliary.py +++ b/src/ansys/geometry/core/misc/auxiliary.py @@ -140,6 +140,26 @@ def __traverse_all_bodies(comp: Union["Design", "Component"]) -> list["Body"]: return bodies +def get_all_bodies_from_design(design: "Design") -> list["Body"]: + """Find all the ``Body`` objects inside a ``Design``. + + Parameters + ---------- + design : Design + Parent design for the bodies. + + Returns + ------- + list[Body] + List of Body objects. + + Notes + ----- + This method takes a design and gets the corresponding ``Body`` objects. + """ + return __traverse_all_bodies(design) + + def get_bodies_from_ids(design: "Design", body_ids: list[str]) -> list["Body"]: """Find the ``Body`` objects inside a ``Design`` from its ids. diff --git a/src/ansys/geometry/core/modeler.py b/src/ansys/geometry/core/modeler.py index 86cd5b0582..bbdc1fdfa2 100644 --- a/src/ansys/geometry/core/modeler.py +++ b/src/ansys/geometry/core/modeler.py @@ -43,12 +43,14 @@ from ansys.geometry.core.tools.measurement_tools import MeasurementTools from ansys.geometry.core.tools.prepare_tools import PrepareTools from ansys.geometry.core.tools.repair_tools import RepairTools +from ansys.geometry.core.tools.unsupported import UnsupportedCommands from ansys.geometry.core.typing import Real if TYPE_CHECKING: # pragma: no cover from ansys.geometry.core.connection.docker_instance import LocalDockerInstance from ansys.geometry.core.connection.product_instance import ProductInstance from ansys.geometry.core.designer.design import Design + from ansys.geometry.core.designer.geometry_commands import GeometryCommands from ansys.platform.instancemanagement import Instance @@ -59,7 +61,7 @@ class Modeler: ---------- host : str, default: DEFAULT_HOST Host where the server is running. - port : Union[str, int], default: DEFAULT_PORT + port : str | int, default: DEFAULT_PORT Port number where the server is running. channel : ~grpc.Channel, default: None gRPC channel for server communication. @@ -103,6 +105,8 @@ def __init__( backend_type: BackendType | None = None, ): """Initialize the ``Modeler`` class.""" + from ansys.geometry.core.designer.geometry_commands import GeometryCommands + self._grpc_client = GrpcClient( host=host, port=port, @@ -116,21 +120,23 @@ def __init__( backend_type=backend_type, ) + # Maintaining references to all designs within the modeler workspace + self._designs: dict[str, "Design"] = {} + # Initialize the RepairTools - Not available on Linux # TODO: delete "if" when Linux service is able to use repair tools # https://github.com/ansys/pyansys-geometry/issues/1319 - if self.client.backend_type == BackendType.LINUX_SERVICE: - self._repair_tools = None - self._prepare_tools = None + if BackendType.is_core_service(self.client.backend_type): self._measurement_tools = None - LOG.warning("Linux backend does not support repair or prepare tools.") + LOG.warning("CoreService backend does not support measurement tools.") else: - self._repair_tools = RepairTools(self._grpc_client) - self._prepare_tools = PrepareTools(self._grpc_client) self._measurement_tools = MeasurementTools(self._grpc_client) - # Maintaining references to all designs within the modeler workspace - self._designs: dict[str, "Design"] = {} + # Enabling tools/commands for all: repair and prepare tools, geometry commands + self._repair_tools = RepairTools(self._grpc_client) + self._prepare_tools = PrepareTools(self._grpc_client) + self._geometry_commands = GeometryCommands(self._grpc_client) + self._unsupported = UnsupportedCommands(self._grpc_client, self) # Check if the backend allows for multiple designs and throw warning if needed if not self.client.multiple_designs_allowed: @@ -509,6 +515,16 @@ def measurement_tools(self) -> MeasurementTools: """Access to measurement tools.""" return self._measurement_tools + @property + def geometry_commands(self) -> "GeometryCommands": + """Access to geometry commands.""" + return self._geometry_commands + + @property + def unsupported(self) -> "UnsupportedCommands": + """Access to unsupported commands.""" + return self._unsupported + @min_backend_version(25, 1, 0) def get_service_logs( self, diff --git a/src/ansys/geometry/core/parameters/__init__.py b/src/ansys/geometry/core/parameters/__init__.py new file mode 100644 index 0000000000..265402d6cc --- /dev/null +++ b/src/ansys/geometry/core/parameters/__init__.py @@ -0,0 +1,24 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""PyAnsys Geometry parameters subpackage.""" + +from ansys.geometry.core.parameters.parameter import Parameter, ParameterType diff --git a/src/ansys/geometry/core/parameters/parameter.py b/src/ansys/geometry/core/parameters/parameter.py new file mode 100644 index 0000000000..6ec223d0a7 --- /dev/null +++ b/src/ansys/geometry/core/parameters/parameter.py @@ -0,0 +1,180 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Provides get and set methods for parameters.""" + +from enum import Enum, unique + +from ansys.api.dbu.v0.dbumodels_pb2 import DrivingDimension as GRPCDrivingDimension +from ansys.api.dbu.v0.drivingdimensions_pb2 import UpdateStatus as GRPCUpdateStatus +from ansys.geometry.core.typing import Real + + +@unique +class ParameterType(Enum): + """Provides values for the parameter types supported.""" + + DIMENSIONTYPE_UNKNOWN = 0 + DIMENSIONTYPE_LINEAR = 1 + DIMENSIONTYPE_DIAMETRIC = 2 + DIMENSIONTYPE_RADIAL = 3 + DIMENSIONTYPE_ARC = 4 + DIMENSIONTYPE_AREA = 5 + DIMENSIONTYPE_VOLUME = 6 + DIMENSIONTYPE_MASS = 7 + DIMENSIONTYPE_ANGULAR = 8 + DIMENSIONTYPE_COUNT = 9 + DIMENSIONTYPE_UNITLESS = 10 + + +@unique +class ParameterUpdateStatus(Enum): + """Provides values for the status messages associated with parameter updates.""" + + SUCCESS = 0 + FAILURE = 1 + CONSTRAINED_PARAMETERS = 2 + UNKNOWN = 3 + + @staticmethod + def _from_update_status(status: GRPCUpdateStatus) -> "ParameterUpdateStatus": + """Convert GRPCUpdateStatus to ParameterUpdateStatus. + + Notes + ----- + This method is used to convert the status of the update from gRPC to the + parameter update status. Not to be used directly by the user. + + Parameters + ---------- + status : GRPCUpdateStatus + Status of the update. Coming from gRPC. + + Returns + ------- + ParameterUpdateStatus + Parameter update status. + """ + status_mapping = { + GRPCUpdateStatus.SUCCESS: ParameterUpdateStatus.SUCCESS, + GRPCUpdateStatus.FAILURE: ParameterUpdateStatus.FAILURE, + GRPCUpdateStatus.CONSTRAINED_PARAMETERS: ParameterUpdateStatus.CONSTRAINED_PARAMETERS, + } + return status_mapping.get(status, ParameterUpdateStatus.UNKNOWN) + + +class Parameter: + """Represents a parameter. + + Parameters + ---------- + id : int + Unique ID for the parameter. + name : str + Name of the parameter. + dimension_type : ParameterType + Type of the parameter. + dimension_value : float + Value of the parameter. + """ + + def __init__(self, id: int, name: str, dimension_type: ParameterType, dimension_value: Real): + """Initialize an instance of the ``Parameter`` class.""" + self.id = id + self._name = name + self._dimension_type = dimension_type + self._dimension_value = dimension_value + + @classmethod + def _from_proto(cls, proto: GRPCDrivingDimension) -> "Parameter": + """Create a ``Parameter`` instance from a ``proto`` object. + + Notes + ----- + This method is used to convert the parameter from gRPC to the parameter + object. Not to be used directly by the user. + + Parameters + ---------- + proto : GRPCDrivingDimension + Parameter object coming from gRPC. + + Returns + ------- + Parameter + Parameter object. + """ + return cls( + id=proto.id, + name=proto.name, + dimension_type=ParameterType(proto.dimension_type), + dimension_value=proto.dimension_value, + ) + + @property + def name(self) -> str: + """Get the name of the parameter.""" + return self._name + + @name.setter + def name(self, value: str): + """Set the name of the parameter.""" + self._name = value + + @property + def dimension_value(self) -> Real: + """Get the value of the parameter.""" + return self._dimension_value + + @dimension_value.setter + def dimension_value(self, value: Real): + """Set the value of the parameter.""" + self._dimension_value = value + + @property + def dimension_type(self) -> ParameterType: + """Get the type of the parameter.""" + return self._dimension_type + + @dimension_type.setter + def dimension_type(self, value: ParameterType): + """Set the type of the parameter.""" + self._dimension_type = value + + def _to_proto(self): + """Convert a ``Parameter`` instance to a ``proto`` object. + + Notes + ----- + This method is used to convert the parameter from the parameter object to + gRPC. Not to be used directly by the user. + + Returns + ------- + GRPCDrivingDimension + Parameter object in gRPC. + """ + return GRPCDrivingDimension( + id=self.id, + name=self.name, + dimension_type=self.dimension_type.value, + dimension_value=self.dimension_value, + ) diff --git a/src/ansys/geometry/core/shapes/__init__.py b/src/ansys/geometry/core/shapes/__init__.py index ebf2466234..64ebb44e03 100644 --- a/src/ansys/geometry/core/shapes/__init__.py +++ b/src/ansys/geometry/core/shapes/__init__.py @@ -25,6 +25,7 @@ from ansys.geometry.core.shapes.curves.curve import Curve from ansys.geometry.core.shapes.curves.ellipse import Ellipse, EllipseEvaluation from ansys.geometry.core.shapes.curves.line import Line, LineEvaluation +from ansys.geometry.core.shapes.curves.nurbs import NURBSCurve, NURBSCurveEvaluation from ansys.geometry.core.shapes.parameterization import ( Interval, Parameterization, diff --git a/src/ansys/geometry/core/shapes/curves/__init__.py b/src/ansys/geometry/core/shapes/curves/__init__.py index 2658b3f654..b786b16208 100644 --- a/src/ansys/geometry/core/shapes/curves/__init__.py +++ b/src/ansys/geometry/core/shapes/curves/__init__.py @@ -26,3 +26,4 @@ from ansys.geometry.core.shapes.curves.curve_evaluation import CurveEvaluation from ansys.geometry.core.shapes.curves.ellipse import Ellipse, EllipseEvaluation from ansys.geometry.core.shapes.curves.line import Line, LineEvaluation +from ansys.geometry.core.shapes.curves.nurbs import NURBSCurve, NURBSCurveEvaluation diff --git a/src/ansys/geometry/core/shapes/curves/nurbs.py b/src/ansys/geometry/core/shapes/curves/nurbs.py new file mode 100644 index 0000000000..08794eeab2 --- /dev/null +++ b/src/ansys/geometry/core/shapes/curves/nurbs.py @@ -0,0 +1,312 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Provides for creating and managing a NURBS curve.""" + +from functools import cached_property +from typing import Optional + +from beartype import beartype as check_input_types +import geomdl.NURBS as geomdl_nurbs # noqa: N811 + +from ansys.geometry.core.math import Matrix44, Point3D +from ansys.geometry.core.math.vector import Vector3D +from ansys.geometry.core.shapes.curves.curve import Curve +from ansys.geometry.core.shapes.curves.curve_evaluation import CurveEvaluation +from ansys.geometry.core.shapes.parameterization import ( + Interval, + Parameterization, + ParamForm, + ParamType, +) +from ansys.geometry.core.typing import Real + + +class NURBSCurve(Curve): + """Represents a NURBS curve. + + Notes + ----- + This class is a wrapper around the NURBS curve class from the `geomdl` library. + By leveraging the `geomdl` library, this class provides a high-level interface + to create and manipulate NURBS curves. The `geomdl` library is a powerful + library for working with NURBS curves and surfaces. For more information, see + https://pypi.org/project/geomdl/. + + """ + + def __init__( + self, + ): + """Initialize ``NURBSCurve`` class.""" + self._nurbs_curve = geomdl_nurbs.Curve() + + @property + def geomdl_nurbs_curve(self) -> geomdl_nurbs.Curve: + """Get the underlying NURBS curve. + + Notes + ----- + This property gives access to the full functionality of the NURBS curve + coming from the `geomdl` library. Use with caution. + """ + return self._nurbs_curve + + @property + def control_points(self) -> list[Point3D]: + """Get the control points of the curve.""" + return [Point3D(point) for point in self._nurbs_curve.ctrlpts] + + @property + def degree(self) -> int: + """Get the degree of the curve.""" + return self._nurbs_curve.degree + + @property + def knots(self) -> list[Real]: + """Get the knot vector of the curve.""" + return self._nurbs_curve.knotvector + + @property + def weights(self) -> list[Real]: + """Get the weights of the control points.""" + return self._nurbs_curve.weights + + @classmethod + @check_input_types + def from_control_points( + cls, + control_points: list[Point3D], + degree: int, + knots: list[Real], + weights: list[Real] = None, + ) -> "NURBSCurve": + """Create a NURBS curve from control points. + + Parameters + ---------- + control_points : list[Point3D] + Control points of the curve. + degree : int + Degree of the curve. + knots : list[Real] + Knot vector of the curve. + weights : list[Real], optional + Weights of the control points. + + Returns + ------- + NURBSCurve + NURBS curve. + + """ + curve = cls() + curve._nurbs_curve.degree = degree + curve._nurbs_curve.ctrlpts = control_points + curve._nurbs_curve.knotvector = knots + if weights: + curve._nurbs_curve.weights = weights + + # Verify the curve is valid + try: + curve._nurbs_curve._check_variables() + except ValueError as e: + raise ValueError(f"Invalid NURBS curve: {e}") + + return curve + + def __eq__(self, other: "NURBSCurve") -> bool: + """Determine if two curves are equal.""" + if not isinstance(other, NURBSCurve): + return False + return ( + self._nurbs_curve.degree == other._nurbs_curve.degree + and self._nurbs_curve.ctrlpts == other._nurbs_curve.ctrlpts + and self._nurbs_curve.knotvector == other._nurbs_curve.knotvector + and self._nurbs_curve.weights == other._nurbs_curve.weights + ) + + def parameterization(self) -> Parameterization: + """Get the parametrization of the NURBS curve. + + The parameter is defined in the interval [0, 1] by default. Information + is provided about the parameter type and form. + + Returns + ------- + Parameterization + Information about how the NURBS curve is parameterized. + """ + return Parameterization( + ParamType.OTHER, + ParamForm.OTHER, + Interval(start=self._nurbs_curve.domain[0], end=self._nurbs_curve.domain[1]), + ) + + def transformed_copy(self, matrix: Matrix44) -> "NURBSCurve": + """Create a transformed copy of the curve. + + Parameters + ---------- + matrix : Matrix44 + Transformation matrix. + + Returns + ------- + NURBSCurve + Transformed copy of the curve. + """ + control_points = [matrix @ point for point in self._nurbs_curve.ctrlpts] + return NURBSCurve.from_control_points( + control_points, + self._nurbs_curve.degree, + self._nurbs_curve.knotvector, + self._nurbs_curve.weights, + ) + + def evaluate(self, parameter: Real) -> CurveEvaluation: + """Evaluate the curve at the given parameter. + + Parameters + ---------- + parameter : Real + Parameter to evaluate the curve at. + + Returns + ------- + CurveEvaluation + Evaluation of the curve at the given parameter. + """ + return NURBSCurveEvaluation(self, parameter) + + def contains_param(self, param: Real) -> bool: # noqa: D102 + raise NotImplementedError("contains_param() is not implemented.") + + def contains_point(self, point: Point3D) -> bool: # noqa: D102 + raise NotImplementedError("contains_point() is not implemented.") + + def project_point( + self, point: Point3D, initial_guess: Optional[Real] = None + ) -> CurveEvaluation: + """Project a point to the NURBS curve. + + This method returns the evaluation at the closest point. + + Notes + ----- + Based on `the NURBS book `_, + the projection of a point to a NURBS curve is the solution to the following optimization + problem: minimize the distance between the point and the curve. The distance is defined + as the Euclidean distance squared. For more information, please refer to + the implementation of the `distance_squared` function. + + Parameters + ---------- + point : Point3D + Point to project to the curve. + initial_guess : Real, optional + Initial guess for the optimization algorithm. If not provided, the midpoint + of the domain is used. + + Returns + ------- + CurveEvaluation + Evaluation at the closest point on the curve. + + """ + import numpy as np + from scipy.optimize import minimize + + # Function to minimize (distance squared) + def distance_squared( + u: float, geomdl_nurbs_curbe: geomdl_nurbs.Curve, point: np.ndarray + ) -> np.ndarray: + point_on_curve = np.array(geomdl_nurbs_curbe.evaluate_single(u)) + return np.sum((point_on_curve - point) ** 2) + + # Define the domain and initial guess (midpoint of the domain by default) + domain = self._nurbs_curve.domain + initial_guess = initial_guess if initial_guess else (domain[0] + domain[1]) / 2 + + # Minimize the distance squared + result = minimize( + distance_squared, + initial_guess, + bounds=[domain], + args=(self._nurbs_curve, np.array(point)), + ) + + # Closest point on the curve + u_min = result.x[0] + + # Return the evaluation at the closest point + return self.evaluate(u_min) + + +class NURBSCurveEvaluation(CurveEvaluation): + """Provides evaluation of a NURBS curve at a given parameter. + + Parameters + ---------- + nurbs_curve: ~ansys.geometry.core.shapes.curves.nurbs.NURBSCurve + NURBS curve to evaluate. + parameter: Real + Parameter to evaluate the NURBS curve at. + """ + + def __init__(self, nurbs_curve: NURBSCurve, parameter: Real) -> None: + """Initialize the ``NURBSCurveEvaluation`` class.""" + self._parameter = parameter + self._point_eval, self._first_deriv_eval, self._second_deriv_eval = ( + nurbs_curve.geomdl_nurbs_curve.derivatives(parameter, 2) + ) + + @property + def parameter(self) -> Real: + """Parameter that the evaluation is based upon.""" + return self._parameter + + @cached_property + def position(self) -> Point3D: + """Position of the evaluation.""" + return Point3D(self._point_eval) + + @cached_property + def first_derivative(self) -> Vector3D: + """First derivative of the evaluation.""" + return Vector3D(self._first_deriv_eval) + + @cached_property + def second_derivative(self) -> Vector3D: + """Second derivative of the evaluation.""" + return Vector3D(self._second_deriv_eval) + + @cached_property + def curvature(self) -> Real: + """Curvature of the evaluation.""" + # For a curve, the curvature is the magnitude of the cross product + # of the first and second derivatives divided by the cube of the + # magnitude of the first derivative. For more information, please refer + # to https://en.wikipedia.org/wiki/Curvature#General_expressions. + return ( + self.first_derivative.cross(self.second_derivative).magnitude + / self.first_derivative.magnitude**3 + ) diff --git a/src/ansys/geometry/core/tools/__init__.py b/src/ansys/geometry/core/tools/__init__.py index 8ce8cc3652..de96b4cc0c 100644 --- a/src/ansys/geometry/core/tools/__init__.py +++ b/src/ansys/geometry/core/tools/__init__.py @@ -30,3 +30,4 @@ ) from ansys.geometry.core.tools.repair_tool_message import RepairToolMessage from ansys.geometry.core.tools.repair_tools import RepairTools +from ansys.geometry.core.tools.unsupported import PersistentIdType, UnsupportedCommands diff --git a/src/ansys/geometry/core/tools/problem_areas.py b/src/ansys/geometry/core/tools/problem_areas.py index fac0cd4352..bea39b436d 100644 --- a/src/ansys/geometry/core/tools/problem_areas.py +++ b/src/ansys/geometry/core/tools/problem_areas.py @@ -27,9 +27,11 @@ from google.protobuf.wrappers_pb2 import Int32Value from ansys.api.geometry.v0.repairtools_pb2 import ( + FixAdjustSimplifyRequest, FixDuplicateFacesRequest, FixExtraEdgesRequest, FixInexactEdgesRequest, + FixInterferenceRequest, FixMissingFacesRequest, FixShortEdgesRequest, FixSmallFacesRequest, @@ -38,6 +40,7 @@ ) from ansys.api.geometry.v0.repairtools_pb2_grpc import RepairToolsStub from ansys.geometry.core.connection import GrpcClient +from ansys.geometry.core.errors import protect_grpc from ansys.geometry.core.misc.auxiliary import ( get_design_from_body, get_design_from_edge, @@ -66,7 +69,7 @@ class ProblemArea: def __init__(self, id: str, grpc_client: GrpcClient): """Initialize a new instance of a problem area class.""" self._id = id - self._id_grpc = Int32Value(value=int(id)) + self._grpc_id = Int32Value(value=int(id)) self._repair_stub = RepairToolsStub(grpc_client.channel) @property @@ -88,7 +91,7 @@ class DuplicateFaceProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. faces : list[Face] @@ -108,9 +111,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, faces: list["Face"]): @property def faces(self) -> list["Face"]: - """The list of the edges connected to this problem area.""" + """The list of faces connected to this problem area.""" return self._faces + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -124,7 +128,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_face(self.faces[0]) response = self._repair_stub.FixDuplicateFaces( - FixDuplicateFacesRequest(duplicate_face_problem_area_id=self._id_grpc) + FixDuplicateFacesRequest(duplicate_face_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -142,7 +146,7 @@ class MissingFaceProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. edges : list[Edge] @@ -162,9 +166,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, edges: list["Edge"]): @property def edges(self) -> list["Edge"]: - """The list of the edges connected to this problem area.""" + """The list of edges connected to this problem area.""" return self._edges + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -178,7 +183,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_edge(self.edges[0]) response = self._repair_stub.FixMissingFaces( - FixMissingFacesRequest(missing_face_problem_area_id=self._id_grpc) + FixMissingFacesRequest(missing_face_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -195,7 +200,7 @@ class InexactEdgeProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. edges : list[Edge] @@ -215,9 +220,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, edges: list["Edge"]): @property def edges(self) -> list["Edge"]: - """The list of the edges connected to this problem area.""" + """The list of edges connected to this problem area.""" return self._edges + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -231,7 +237,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_edge(self.edges[0]) response = self._repair_stub.FixInexactEdges( - FixInexactEdgesRequest(inexact_edge_problem_area_id=self._id_grpc) + FixInexactEdgesRequest(inexact_edge_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -248,7 +254,7 @@ class ExtraEdgeProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. edges : list[Edge] @@ -268,9 +274,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, edges: list["Edge"]): @property def edges(self) -> list["Edge"]: - """The list of the ids of the edges connected to this problem area.""" + """The list of edges connected to this problem area.""" return self._edges + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -283,7 +290,7 @@ def fix(self) -> RepairToolMessage: return RepairToolMessage(False, [], []) parent_design = get_design_from_edge(self.edges[0]) - request = FixExtraEdgesRequest(extra_edge_problem_area_id=self._id_grpc) + request = FixExtraEdgesRequest(extra_edge_problem_area_id=self._grpc_id) response = self._repair_stub.FixExtraEdges(request) parent_design._update_design_inplace() message = RepairToolMessage( @@ -301,7 +308,7 @@ class ShortEdgeProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. edges : list[Edge] @@ -321,9 +328,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, edges: list["Edge"]): @property def edges(self) -> list["Edge"]: - """The list of the ids of the edges connected to this problem area.""" + """The list of edges connected to this problem area.""" return self._edges + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -337,7 +345,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_edge(self.edges[0]) response = self._repair_stub.FixShortEdges( - FixShortEdgesRequest(short_edge_problem_area_id=self._id_grpc) + FixShortEdgesRequest(short_edge_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -355,7 +363,7 @@ class SmallFaceProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. faces : list[Face] @@ -375,9 +383,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, faces: list["Face"]): @property def faces(self) -> list["Face"]: - """The list of the ids of the edges connected to this problem area.""" + """The list of faces connected to this problem area.""" return self._faces + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -391,7 +400,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_face(self.faces[0]) response = self._repair_stub.FixSmallFaces( - FixSmallFacesRequest(small_face_problem_area_id=self._id_grpc) + FixSmallFacesRequest(small_face_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -408,7 +417,7 @@ class SplitEdgeProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. edges : list[Edge] @@ -431,6 +440,7 @@ def edges(self) -> list["Edge"]: """The list of edges connected to this problem area.""" return self._edges + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -444,7 +454,7 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_edge(self.edges[0]) response = self._repair_stub.FixSplitEdges( - FixSplitEdgesRequest(split_edge_problem_area_id=self._id_grpc) + FixSplitEdgesRequest(split_edge_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -461,7 +471,7 @@ class StitchFaceProblemAreas(ProblemArea): Parameters ---------- id : str - Server-defined ID for the body. + Server-defined ID for the problem area. grpc_client : GrpcClient Active supporting geometry service instance for design modeling. bodies : list[Body] @@ -481,9 +491,10 @@ def __init__(self, id: str, grpc_client: GrpcClient, bodies: list["Body"]): @property def bodies(self) -> list["Body"]: - """The list of the bodies connected to this problem area.""" + """The list of bodies connected to this problem area.""" return self._bodies + @protect_grpc def fix(self) -> RepairToolMessage: """Fix the problem area. @@ -497,7 +508,56 @@ def fix(self) -> RepairToolMessage: parent_design = get_design_from_body(self.bodies[0]) response = self._repair_stub.FixStitchFaces( - FixStitchFacesRequest(stitch_face_problem_area_id=self._id_grpc) + FixStitchFacesRequest(stitch_face_problem_area_id=self._grpc_id) + ) + parent_design._update_design_inplace() + message = RepairToolMessage( + response.result.success, + response.result.created_bodies_monikers, + response.result.modified_bodies_monikers, + ) + return message + + +class UnsimplifiedFaceProblemAreas(ProblemArea): + """Represents a unsimplified face problem area with unique identifier and associated faces. + + Parameters + ---------- + id : str + Server-defined ID for the problem area. + grpc_client : GrpcClient + Active supporting geometry service instance for design modeling. + faces : list[Face] + List of faces associated with the design. + """ + + def __init__(self, id: str, grpc_client: GrpcClient, faces: list["Face"]): + """Initialize a new instance of the unsimplified face problem area class.""" + super().__init__(id, grpc_client) + + self._faces = faces + + @property + def faces(self) -> list["Face"]: + """The list of faces connected to this problem area.""" + return self._faces + + @protect_grpc + def fix(self) -> RepairToolMessage: + """Fix the problem area. + + Returns + ------- + message: RepairToolMessage + Message containing created and/or modified bodies. + """ + if not self.faces: + return RepairToolMessage(False, [], []) + + parent_design = get_design_from_face(self.faces[0]) + response = self._repair_stub.FixAdjustSimplify( + FixAdjustSimplifyRequest(adjust_simplify_problem_area_id=self._grpc_id) ) parent_design._update_design_inplace() message = RepairToolMessage( @@ -506,3 +566,56 @@ def fix(self) -> RepairToolMessage: response.result.modified_bodies_monikers, ) return message + + +class InterferenceProblemAreas(ProblemArea): + """Represents an interference problem area with a unique identifier and associated bodies. + + Parameters + ---------- + id : str + Server-defined ID for the problem area. + grpc_client : GrpcClient + Active supporting geometry service instance for design modeling. + bodies : list[Body] + List of bodies in the problem area. + """ + + def __init__(self, id: str, grpc_client: GrpcClient, bodies: list["Body"]): + """Initialize a new instance of the interference problem area class.""" + super().__init__(id, grpc_client) + + self._bodies = bodies + + @property + def bodies(self) -> list["Body"]: + """The list of the bodies connected to this problem area.""" + return self._bodies + + @protect_grpc + def fix(self) -> RepairToolMessage: + """Fix the problem area. + + Returns + ------- + message: RepairToolMessage + Message containing created and/or modified bodies. + + Notes + ----- + The current implementation does not properly track changes. + The list of created and modified bodies are empty. + """ + if not self.bodies: + return RepairToolMessage(False, [], []) + + parent_design = get_design_from_body(self.bodies[0]) + response = self._repair_stub.FixInterference( + FixInterferenceRequest(interference_problem_area_id=self._grpc_id) + ) + parent_design._update_design_inplace() + ## The tool does not return the created or modified objects. + ## https://github.com/ansys/pyansys-geometry/issues/1319 + message = RepairToolMessage(response.result.success, [], []) + + return message diff --git a/src/ansys/geometry/core/tools/repair_tools.py b/src/ansys/geometry/core/tools/repair_tools.py index 74ae2246a7..5571a27576 100644 --- a/src/ansys/geometry/core/tools/repair_tools.py +++ b/src/ansys/geometry/core/tools/repair_tools.py @@ -23,13 +23,15 @@ from typing import TYPE_CHECKING -from google.protobuf.wrappers_pb2 import DoubleValue +from google.protobuf.wrappers_pb2 import BoolValue, DoubleValue from ansys.api.geometry.v0.bodies_pb2_grpc import BodiesStub from ansys.api.geometry.v0.repairtools_pb2 import ( + FindAdjustSimplifyRequest, FindDuplicateFacesRequest, FindExtraEdgesRequest, FindInexactEdgesRequest, + FindInterferenceRequest, FindMissingFacesRequest, FindShortEdgesRequest, FindSmallFacesRequest, @@ -38,22 +40,31 @@ ) from ansys.api.geometry.v0.repairtools_pb2_grpc import RepairToolsStub from ansys.geometry.core.connection import GrpcClient +from ansys.geometry.core.errors import protect_grpc from ansys.geometry.core.misc.auxiliary import ( get_bodies_from_ids, get_design_from_body, get_edges_from_ids, get_faces_from_ids, ) +from ansys.geometry.core.misc.checks import ( + check_type, + check_type_all_elements_in_iterable, + min_backend_version, +) from ansys.geometry.core.tools.problem_areas import ( DuplicateFaceProblemAreas, ExtraEdgeProblemAreas, InexactEdgeProblemAreas, + InterferenceProblemAreas, MissingFaceProblemAreas, ShortEdgeProblemAreas, SmallFaceProblemAreas, SplitEdgeProblemAreas, StitchFaceProblemAreas, + UnsimplifiedFaceProblemAreas, ) +from ansys.geometry.core.tools.repair_tool_message import RepairToolMessage from ansys.geometry.core.typing import Real if TYPE_CHECKING: # pragma: no cover @@ -69,6 +80,7 @@ def __init__(self, grpc_client: GrpcClient): self._repair_stub = RepairToolsStub(self._grpc_client.channel) self._bodies_stub = BodiesStub(self._grpc_client.channel) + @protect_grpc def find_split_edges( self, bodies: list["Body"], angle: Real = 0.0, length: Real = 0.0 ) -> list[SplitEdgeProblemAreas]: @@ -113,6 +125,7 @@ def find_split_edges( for res in problem_areas_response.result ] + @protect_grpc def find_extra_edges(self, bodies: list["Body"]) -> list[ExtraEdgeProblemAreas]: """Find the extra edges in the given list of bodies. @@ -147,6 +160,7 @@ def find_extra_edges(self, bodies: list["Body"]) -> list[ExtraEdgeProblemAreas]: for res in problem_areas_response.result ] + @protect_grpc def find_inexact_edges(self, bodies: list["Body"]) -> list[InexactEdgeProblemAreas]: """Find inexact edges in the given list of bodies. @@ -182,6 +196,7 @@ def find_inexact_edges(self, bodies: list["Body"]) -> list[InexactEdgeProblemAre for res in problem_areas_response.result ] + @protect_grpc def find_short_edges( self, bodies: list["Body"], length: Real = 0.0 ) -> list[ShortEdgeProblemAreas]: @@ -220,6 +235,7 @@ def find_short_edges( for res in problem_areas_response.result ] + @protect_grpc def find_duplicate_faces(self, bodies: list["Body"]) -> list[DuplicateFaceProblemAreas]: """Find the duplicate face problem areas. @@ -254,6 +270,7 @@ def find_duplicate_faces(self, bodies: list["Body"]) -> list[DuplicateFaceProble for res in problem_areas_response.result ] + @protect_grpc def find_missing_faces(self, bodies: list["Body"]) -> list[MissingFaceProblemAreas]: """Find the missing faces. @@ -287,6 +304,7 @@ def find_missing_faces(self, bodies: list["Body"]) -> list[MissingFaceProblemAre for res in problem_areas_response.result ] + @protect_grpc def find_small_faces(self, bodies: list["Body"]) -> list[SmallFaceProblemAreas]: """Find the small face problem areas. @@ -321,6 +339,7 @@ def find_small_faces(self, bodies: list["Body"]) -> list[SmallFaceProblemAreas]: for res in problem_areas_response.result ] + @protect_grpc def find_stitch_faces(self, bodies: list["Body"]) -> list[StitchFaceProblemAreas]: """Return the list of stitch face problem areas. @@ -350,3 +369,232 @@ def find_stitch_faces(self, bodies: list["Body"]) -> list[StitchFaceProblemAreas ) for res in problem_areas_response.result ] + + @protect_grpc + @min_backend_version(25, 2, 0) + def find_simplify(self, bodies: list["Body"]) -> list[UnsimplifiedFaceProblemAreas]: + """Detect faces in a body that can be simplified. + + Parameters + ---------- + bodies : list[Body] + List of bodies to search. + + Returns + ------- + list[UnsimplifiedFaceProblemAreas] + List of objects representing unsimplified face problem areas. + """ + from ansys.geometry.core.designer.body import Body + + check_type_all_elements_in_iterable(bodies, Body) + body_ids = [body.id for body in bodies] + + parent_design = get_design_from_body(bodies[0]) + problem_areas_response = self._repair_stub.FindAdjustSimplify( + FindAdjustSimplifyRequest( + selection=body_ids, + ) + ) + + return [ + UnsimplifiedFaceProblemAreas( + f"{res.id}", + self._grpc_client, + get_faces_from_ids(parent_design, res.body_monikers), + ) + for res in problem_areas_response.result + ] + + @protect_grpc + @min_backend_version(25, 2, 0) + def find_interferences( + self, bodies: list["Body"], cut_smaller_body: bool = False + ) -> list[InterferenceProblemAreas]: + """Find the interference problem areas. + + Notes + ----- + This method finds and returns a list of ids of interference problem areas + objects. + + Parameters + ---------- + bodies : list[Body] + List of bodies that small faces are investigated on. + cut_smaller_body : bool, optional + Whether to cut the smaller body if an intererference is found. + By default, False. + + Returns + ------- + list[InterfenceProblemAreas] + List of objects representing interference problem areas. + """ + from ansys.geometry.core.designer.body import Body + + if not bodies: + return [] + + # Verify inputs + check_type_all_elements_in_iterable(bodies, Body) + check_type(cut_smaller_body, bool) + + parent_design = get_design_from_body(bodies[0]) + body_ids = [body.id for body in bodies] + cut_smaller_body_bool = BoolValue(value=cut_smaller_body) + problem_areas_response = self._repair_stub.FindInterference( + FindInterferenceRequest(bodies=body_ids, cut_smaller_body=cut_smaller_body_bool) + ) + + return [ + InterferenceProblemAreas( + f"{res.id}", + self._grpc_client, + get_bodies_from_ids(parent_design, res.body_monikers), + ) + for res in problem_areas_response.result + ] + + @protect_grpc + @min_backend_version(25, 2, 0) + def find_and_fix_short_edges( + self, bodies: list["Body"], length: Real = 0.0 + ) -> RepairToolMessage: + """Find and fix the short edge problem areas. + + Notes + ----- + This method finds the short edges in the bodies and fixes them. + + Parameters + ---------- + bodies : list[Body] + List of bodies that short edges are investigated on. + length : Real, optional + The maximum length of the edges. By default, 0.0. + + Returns + ------- + RepairToolMessage + Message containing created and/or modified bodies. + """ + from ansys.geometry.core.designer.body import Body + + check_type_all_elements_in_iterable(bodies, Body) + check_type(length, Real) + + if not bodies: + return RepairToolMessage(False, [], []) + + response = self._repair_stub.FindAndFixShortEdges( + FindShortEdgesRequest( + selection=[body.id for body in bodies], + max_edge_length=DoubleValue(value=length), + ) + ) + + parent_design = get_design_from_body(bodies[0]) + parent_design._update_design_inplace() + message = RepairToolMessage( + response.success, + response.created_bodies_monikers, + response.modified_bodies_monikers, + ) + return message + + @protect_grpc + @min_backend_version(25, 2, 0) + def find_and_fix_extra_edges(self, bodies: list["Body"]) -> RepairToolMessage: + """Find and fix the extra edge problem areas. + + Notes + ----- + This method finds the extra edges in the bodies and fixes them. + + Parameters + ---------- + bodies : list[Body] + List of bodies that short edges are investigated on. + length : Real + The maximum length of the edges. + + Returns + ------- + RepairToolMessage + Message containing created and/or modified bodies. + """ + from ansys.geometry.core.designer.body import Body + + check_type_all_elements_in_iterable(bodies, Body) + + if not bodies: + return RepairToolMessage(False, [], []) + + response = self._repair_stub.FindAndFixExtraEdges( + FindExtraEdgesRequest( + selection=[body.id for body in bodies], + ) + ) + + parent_design = get_design_from_body(bodies[0]) + parent_design._update_design_inplace() + message = RepairToolMessage( + response.success, + response.created_bodies_monikers, + response.modified_bodies_monikers, + ) + return message + + @protect_grpc + @min_backend_version(25, 2, 0) + def find_and_fix_split_edges( + self, bodies: list["Body"], angle: Real = 0.0, length: Real = 0.0 + ) -> RepairToolMessage: + """Find and fix the split edge problem areas. + + Notes + ----- + This method finds the extra edges in the bodies and fixes them. + + Parameters + ---------- + bodies : list[Body] + List of bodies that split edges are investigated on. + angle : Real, optional + The maximum angle between edges. By default, 0.0. + length : Real, optional + The maximum length of the edges. By default, 0.0. + + Returns + ------- + RepairToolMessage + Message containing created and/or modified bodies. + """ + from ansys.geometry.core.designer.body import Body + + check_type_all_elements_in_iterable(bodies, Body) + check_type(angle, Real) + check_type(length, Real) + + if not bodies: + return RepairToolMessage(False, [], []) + + angle_value = DoubleValue(value=float(angle)) + length_value = DoubleValue(value=float(length)) + body_ids = [body.id for body in bodies] + + response = self._repair_stub.FindAndFixSplitEdges( + FindSplitEdgesRequest( + bodies_or_faces=body_ids, angle=angle_value, distance=length_value + ) + ) + + parent_design = get_design_from_body(bodies[0]) + parent_design._update_design_inplace() + message = RepairToolMessage( + response.success, + response.created_bodies_monikers, + response.modified_bodies_monikers, + ) + return message diff --git a/src/ansys/geometry/core/tools/unsupported.py b/src/ansys/geometry/core/tools/unsupported.py new file mode 100644 index 0000000000..343810fada --- /dev/null +++ b/src/ansys/geometry/core/tools/unsupported.py @@ -0,0 +1,252 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Unsupported functions for the PyAnsys Geometry library.""" + +from enum import Enum, unique +from typing import TYPE_CHECKING + +from ansys.api.dbu.v0.dbumodels_pb2 import EntityIdentifier +from ansys.api.geometry.v0.unsupported_pb2 import ExportIdRequest, ImportIdRequest +from ansys.api.geometry.v0.unsupported_pb2_grpc import UnsupportedStub +from ansys.geometry.core.connection import GrpcClient +from ansys.geometry.core.errors import protect_grpc +from ansys.geometry.core.misc.auxiliary import get_all_bodies_from_design +from ansys.geometry.core.misc.checks import ( + min_backend_version, +) + +if TYPE_CHECKING: # pragma: no cover + from ansys.geometry.core.designer.body import Body + from ansys.geometry.core.designer.edge import Edge + from ansys.geometry.core.designer.face import Face + from ansys.geometry.core.modeler import Modeler + + +@unique +class PersistentIdType(Enum): + """Type of persistent id.""" + + PNAME = 1 + PRIME_ID = 700 + + +class UnsupportedCommands: + """Provides unsupported commands for PyAnsys Geometry. + + Parameters + ---------- + grpc_client : GrpcClient + gRPC client to use for the geometry commands. + modeler : Modeler + Modeler instance to use for the geometry commands. + """ + + def __init__(self, grpc_client: GrpcClient, modeler: "Modeler"): + """Initialize an instance of the ``UnsupportedCommands`` class.""" + self._grpc_client = grpc_client + self._unsupported_stub = UnsupportedStub(self._grpc_client.channel) + self.__id_map = {} + self.__modeler = modeler + self.__current_design = modeler.get_active_design() + + @protect_grpc + @min_backend_version(25, 2, 0) + def __fill_imported_id_map(self, id_type: PersistentIdType) -> None: + """Populate the persistent id map for caching. + + Parameters + ---------- + id_type : PersistentIdType + Type of id. + + Notes + ----- + This cache should be cleared on design change. + """ + request = ImportIdRequest(type=id_type.value) + self.__id_map[id_type] = self._unsupported_stub.GetImportIdMap(request).id_map + + def __clear_cache(self) -> None: + """Clear the cache of persistent id's. + + Notes + ----- + This should be called on design change. + """ + self.__id_map = {} + + def __is_occurrence(self, master: EntityIdentifier, occ: str) -> bool: + """Determine if the master is the master of the occurrence. + + Parameters + ---------- + master : EntityIdentifier + Master moniker. + occ : str + Occurrence moniker. + + Returns + ------- + bool + ``True`` if the master is the master of the occurrence. + + """ + master_id = occ.split("/")[-1] + return master.id == master_id + + def __get_moniker_from_import_id( + self, id_type: PersistentIdType, import_id: str + ) -> EntityIdentifier | None: + """Look up the moniker from the id map. + + Parameters + ---------- + id_type : PersistentIdType + Type of id. + import_id : str + Persistent id. + + Returns + ------- + EntityIdentifier + Moniker associated with the id or None + + Notes + ----- + This checks if the design has changed and clears the cache if it has. + """ + if ( + self.__current_design != self.__modeler.get_active_design() + or id_type not in self.__id_map + ): + self.__fill_imported_id_map(id_type) + moniker = self.__id_map[id_type].get(import_id, None) + return moniker + + @protect_grpc + @min_backend_version(25, 2, 0) + def set_export_id(self, moniker: str, id_type: PersistentIdType, value: str) -> None: + """Set the persistent id for the moniker. + + Parameters + ---------- + moniker : str + Moniker to set the id for. + id_type : PersistentIdType + Type of id. + value : str + Id to set. + """ + request = ExportIdRequest( + moniker=EntityIdentifier(id=moniker), id=value, type=id_type.value + ) + self._unsupported_stub.SetExportId(request) + self.__id_map = {} + + def get_body_occurrences_from_import_id( + self, import_id: str, id_type: PersistentIdType + ) -> list["Body"]: + """Get all body occurrences whose master has the given import id. + + Parameters + ---------- + import_id : str + Persistent id + id_type : PersistentIdType + Type of id + + Returns + ------- + list[Body] + List of body occurrences. + """ + moniker = self.__get_moniker_from_import_id(id_type, import_id) + + if moniker is None: + return [] + + design = self.__modeler.get_active_design() + return [ + body + for body in get_all_bodies_from_design(design) + if self.__is_occurrence(moniker, body.id) + ] + + def get_face_occurrences_from_import_id( + self, import_id: str, id_type: PersistentIdType + ) -> list["Face"]: + """Get all face occurrences whose master has the given import id. + + Parameters + ---------- + import_id : str + Persistent id. + id_type : PersistentIdType + Type of id. + + Returns + ------- + list[Face] + List of face occurrences. + """ + moniker = self.__get_moniker_from_import_id(id_type, import_id) + + if moniker is None: + return [] + + design = self.__modeler.get_active_design() + return [ + face + for body in get_all_bodies_from_design(design) + for face in body.faces + if self.__is_occurrence(moniker, face.id) + ] + + def get_edge_occurrences_from_import_id( + self, import_id: str, id_type: PersistentIdType + ) -> list["Edge"]: + """Get all edge occurrences whose master has the given import id. + + Parameters + ---------- + import_id : str + Persistent id. + id_type : PersistentIdType + Type of id. + + Returns + ------- + list[Edge] + List of edge occurrences. + """ + moniker = self.__get_moniker_from_import_id(id_type, import_id) + + if moniker is None: + return [] + + design = self.__modeler.get_active_design() + return [ + edge + for body in get_all_bodies_from_design(design) + for edge in body.edges + if self.__is_occurrence(moniker, edge.id) + ] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 51cc6e754e..102a53851a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -49,11 +49,31 @@ FILES_DIR = Path(Path(__file__).parent, "files") -def skip_if_linux(modeler: Modeler, test_name: str, element_not_available: str): - """Skip test if running on Linux.""" - if modeler.client.backend_type == BackendType.LINUX_SERVICE: +def skip_if_core_service(modeler: Modeler, test_name: str, element_not_available: str): + """Skip test if running on CoreService.""" + if BackendType.is_core_service(modeler.client.backend_type): pytest.skip( - reason=f"Skipping '{test_name}'. '{element_not_available}' not on Linux service." + reason=f"Skipping '{test_name}'. '{element_not_available}' not on CoreService." + ) # skip! + + +def skip_if_windows(modeler: Modeler, test_name: str, element_not_available: str): + """Skip test if running on Windows.""" + if modeler.client.backend_type in ( + BackendType.SPACECLAIM, + BackendType.WINDOWS_SERVICE, + BackendType.DISCOVERY, + ): + pytest.skip( + reason=f"Skipping '{test_name}'. '{element_not_available}' not on Windows services." + ) # skip! + + +def skip_if_spaceclaim(modeler: Modeler, test_name: str, element_not_available: str): + """Skip test if running on SpaceClaim.""" + if modeler.client.backend_type == BackendType.SPACECLAIM: + pytest.skip( + reason=f"Skipping '{test_name}'. '{element_not_available}' not on SpaceClaim." ) # skip! @@ -116,7 +136,7 @@ def docker_instance(use_existing_service): @pytest.fixture(scope="session") -def modeler(docker_instance): +def session_modeler(docker_instance): # Log to file - accepts str or Path objects, Path is passed for testing/coverage purposes. log_file_path = Path(__file__).absolute().parent / "logs" / "integration_tests_logs.txt" @@ -136,6 +156,18 @@ def modeler(docker_instance): assert modeler.client.is_closed +@pytest.fixture(scope="function") +def modeler(session_modeler: Modeler): + # Yield the modeler + yield session_modeler + + # Cleanup on exit + [design.close() for design in session_modeler.designs.values()] + + # Empty the designs dictionary + session_modeler._designs = {} + + @pytest.fixture(scope="session", autouse=True) def clean_plot_result_images(): """Method cleaning up the image results path. diff --git a/tests/integration/files/DuplicateFaces.scdocx b/tests/integration/files/DuplicateFaces.scdocx new file mode 100644 index 0000000000..b122fd6936 Binary files /dev/null and b/tests/integration/files/DuplicateFaces.scdocx differ diff --git a/tests/integration/files/Edge_Slice_Test.dsco b/tests/integration/files/Edge_Slice_Test.dsco new file mode 100644 index 0000000000..f890adaa6d Binary files /dev/null and b/tests/integration/files/Edge_Slice_Test.dsco differ diff --git a/tests/integration/files/ExtraEdges.scdocx b/tests/integration/files/ExtraEdges.scdocx new file mode 100644 index 0000000000..5b9b0efc3b Binary files /dev/null and b/tests/integration/files/ExtraEdges.scdocx differ diff --git a/tests/integration/files/ExtraEdges_NoComponents.scdocx b/tests/integration/files/ExtraEdges_NoComponents.scdocx new file mode 100644 index 0000000000..686ac81e20 Binary files /dev/null and b/tests/integration/files/ExtraEdges_NoComponents.scdocx differ diff --git a/tests/integration/files/MissingFaces.scdocx b/tests/integration/files/MissingFaces.scdocx new file mode 100644 index 0000000000..b89d5d8488 Binary files /dev/null and b/tests/integration/files/MissingFaces.scdocx differ diff --git a/tests/integration/files/SOBracket2.scdocx b/tests/integration/files/SOBracket2.scdocx new file mode 100644 index 0000000000..9bf5baac9e Binary files /dev/null and b/tests/integration/files/SOBracket2.scdocx differ diff --git a/tests/integration/files/ShortEdges.scdocx b/tests/integration/files/ShortEdges.scdocx new file mode 100644 index 0000000000..86ab1f1245 Binary files /dev/null and b/tests/integration/files/ShortEdges.scdocx differ diff --git a/tests/integration/files/SimpleInterference.scdocx b/tests/integration/files/SimpleInterference.scdocx new file mode 100644 index 0000000000..5470460cb1 Binary files /dev/null and b/tests/integration/files/SimpleInterference.scdocx differ diff --git a/tests/integration/files/SmallFaces.scdocx b/tests/integration/files/SmallFaces.scdocx new file mode 100644 index 0000000000..86ab1f1245 Binary files /dev/null and b/tests/integration/files/SmallFaces.scdocx differ diff --git a/tests/integration/files/Stitch_And_MissingFaces.scdocx b/tests/integration/files/Stitch_And_MissingFaces.scdocx new file mode 100644 index 0000000000..98808347f7 Binary files /dev/null and b/tests/integration/files/Stitch_And_MissingFaces.scdocx differ diff --git a/tests/integration/files/bracket-with-split-edges.scdocx b/tests/integration/files/bracket-with-split-edges.scdocx new file mode 100644 index 0000000000..b2eb13fe94 Binary files /dev/null and b/tests/integration/files/bracket-with-split-edges.scdocx differ diff --git a/tests/integration/files/gear.scdocx b/tests/integration/files/gear.scdocx new file mode 100644 index 0000000000..70378d6cae Binary files /dev/null and b/tests/integration/files/gear.scdocx differ diff --git a/tests/integration/files/import/twoCars.stride b/tests/integration/files/import/twoCars.stride new file mode 100644 index 0000000000..08f75a4e39 Binary files /dev/null and b/tests/integration/files/import/twoCars.stride differ diff --git a/tests/integration/test_design.py b/tests/integration/test_design.py index 7a01881ed8..9dc0bbf0b0 100644 --- a/tests/integration/test_design.py +++ b/tests/integration/test_design.py @@ -56,12 +56,14 @@ Vector3D, ) from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Angle, Distance +from ansys.geometry.core.parameters.parameter import ParameterType, ParameterUpdateStatus from ansys.geometry.core.shapes import ( Circle, Cone, Cylinder, Ellipse, Interval, + Line, ParamUV, Sphere, Torus, @@ -70,7 +72,7 @@ from ansys.geometry.core.sketch import Sketch from ansys.tools.visualization_interface.utils.color import Color -from .conftest import FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, skip_if_core_service def test_design_extrusion_and_material_assignment(modeler: Modeler): @@ -146,6 +148,57 @@ def test_design_extrusion_and_material_assignment(modeler: Modeler): # design.save(r"C:\temp\shared_volume\MyFile2.scdocx") +def test_assigning_and_getting_material(modeler: Modeler): + """Test the assignment and retrieval of materials from a design.""" + # Create a Sketch and draw a circle (all client side) + sketch = Sketch() + sketch.circle(Point2D([10, 10], UNITS.mm), Quantity(10, UNITS.mm)) + + # Create your design on the server side + design_name = "ExtrudeProfile" + design = modeler.create_design(design_name) + + # Add a material to body + density = Quantity(125, 1000 * UNITS.kg / (UNITS.m**3)) + poisson_ratio = Quantity(0.33, UNITS.dimensionless) + tensile_strength = Quantity(45.0, UNITS.pascal) + material = Material( + "steel", + density, + [MaterialProperty(MaterialPropertyType.POISSON_RATIO, "myPoisson", poisson_ratio)], + ) + material.add_property(MaterialPropertyType.TENSILE_STRENGTH, "myTensile", Quantity(45)) + design.add_material(material) + + # Extrude the sketch to create a Body + body = design.extrude_sketch("JustACircle", sketch, Quantity(10, UNITS.mm)) + + # Assign a material to a Body + body.material = material + mat_service = body.material + + # Test material and property retrieval + assert mat_service.name == "steel" + assert len(mat_service.properties) == 3 + assert mat_service.properties[MaterialPropertyType.DENSITY].type == MaterialPropertyType.DENSITY + assert mat_service.properties[MaterialPropertyType.DENSITY].name == "Density" + assert mat_service.properties[MaterialPropertyType.DENSITY].quantity == density + assert ( + mat_service.properties[MaterialPropertyType.POISSON_RATIO].type + == MaterialPropertyType.POISSON_RATIO + ) + assert mat_service.properties[MaterialPropertyType.POISSON_RATIO].name == "myPoisson" + assert mat_service.properties[MaterialPropertyType.POISSON_RATIO].quantity == poisson_ratio + assert ( + mat_service.properties[MaterialPropertyType.TENSILE_STRENGTH].type + == MaterialPropertyType.TENSILE_STRENGTH + ) + assert mat_service.properties[MaterialPropertyType.TENSILE_STRENGTH].name == "myTensile" + assert ( + mat_service.properties[MaterialPropertyType.TENSILE_STRENGTH].quantity == tensile_strength + ) + + def test_face_to_body_creation(modeler: Modeler): """Test in charge of validating the extrusion of an existing face.""" # Create a Sketch and draw a circle (all client side) @@ -911,7 +964,7 @@ def test_download_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactor assert file.exists() # Check that we can also save it (even if it is not accessible on the server) - if modeler.client.backend_type == BackendType.LINUX_SERVICE: + if modeler.client.backend_type in (BackendType.LINUX_SERVICE, BackendType.CORE_LINUX): file_save = "/tmp/cylinder-temp.scdocx" else: file_save = tmp_path_factory.mktemp("scdoc_files_save") / "cylinder.scdocx" @@ -919,7 +972,7 @@ def test_download_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactor design.save(file_location=file_save) # Check for other exports - Windows backend... - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): binary_parasolid_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.x_b" text_parasolid_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.x_t" @@ -932,6 +985,11 @@ def test_download_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactor design.download(iges_file, format=DesignFileFormat.IGES) assert iges_file.exists() + # FMD + fmd_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.fmd" + design.download(fmd_file, format=DesignFileFormat.FMD) + assert fmd_file.exists() + # Linux backend... else: binary_parasolid_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.xmt_bin" @@ -940,17 +998,12 @@ def test_download_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactor # PMDB pmdb_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.pmdb" - # FMD - fmd_file = tmp_path_factory.mktemp("scdoc_files_download") / "cylinder.fmd" - design.download(binary_parasolid_file, format=DesignFileFormat.PARASOLID_BIN) design.download(text_parasolid_file, format=DesignFileFormat.PARASOLID_TEXT) - design.download(fmd_file, format=DesignFileFormat.FMD) design.download(pmdb_file, format=DesignFileFormat.PMDB) assert binary_parasolid_file.exists() assert text_parasolid_file.exists() - assert fmd_file.exists() assert pmdb_file.exists() @@ -1111,8 +1164,8 @@ def test_copy_body(modeler: Modeler): def test_beams(modeler: Modeler): """Test beam creation.""" - # Skip on Linux - skip_if_linux(modeler, test_beams.__name__, "create_beam") + # Skip on CoreService + skip_if_core_service(modeler, test_beams.__name__, "create_beam") # Create your design on the server side design = modeler.create_design("BeamCreation") @@ -1414,8 +1467,8 @@ def test_named_selections_beams(modeler: Modeler): """Test for verifying the correct creation of ``NamedSelection`` with beams. """ - # Skip on Linux - skip_if_linux(modeler, test_named_selections_beams.__name__, "create_beam") + # Skip on CoreService + skip_if_core_service(modeler, test_named_selections_beams.__name__, "create_beam") # Create your design on the server side design = modeler.create_design("NamedSelectionBeams_Test") @@ -1622,20 +1675,20 @@ def test_boolean_body_operations(modeler: Modeler): # 1.b.ii copy1 = body1.copy(comp1, "Copy1") copy1a = body1.copy(comp1, "Copy1a") - with pytest.raises(ValueError): - copy1.subtract(copy1a) + copy1.subtract(copy1a) assert copy1.is_alive - assert copy1a.is_alive + assert not copy1a.is_alive # 1.b.iii copy1 = body1.copy(comp1, "Copy1") copy3 = body3.copy(comp3, "Copy3") - copy1.subtract(copy3) + with pytest.raises(ValueError): + copy1.subtract(copy3) assert Accuracy.length_is_equal(copy1.volume.m, 1) assert copy1.volume - assert not copy3.is_alive + assert copy3.is_alive # 1.c.i.x copy1 = body1.copy(comp1, "Copy1") @@ -1658,9 +1711,10 @@ def test_boolean_body_operations(modeler: Modeler): # 1.c.ii copy1 = body1.copy(comp1, "Copy1") copy3 = body3.copy(comp3, "Copy3") - copy1.unite(copy3) + with pytest.raises(ValueError): + copy1.unite(copy3) - assert not copy3.is_alive + assert copy3.is_alive assert body3.is_alive assert Accuracy.length_is_equal(copy1.volume.m, 1) @@ -1731,20 +1785,20 @@ def test_boolean_body_operations(modeler: Modeler): # 2.b.ii copy1 = body1.copy(comp1_i, "Copy1") copy1a = body1.copy(comp1_i, "Copy1a") - with pytest.raises(ValueError): - copy1.subtract(copy1a) + copy1.subtract(copy1a) assert copy1.is_alive - assert copy1a.is_alive + assert not copy1a.is_alive # 2.b.iii copy1 = body1.copy(comp1_i, "Copy1") copy3 = body3.copy(comp3_i, "Copy3") - copy1.subtract(copy3) + with pytest.raises(ValueError): + copy1.subtract(copy3) assert Accuracy.length_is_equal(copy1.volume.m, 1) assert copy1.volume - assert not copy3.is_alive + assert copy3.is_alive # 2.c.i.x copy1 = body1.copy(comp1_i, "Copy1") @@ -1767,9 +1821,10 @@ def test_boolean_body_operations(modeler: Modeler): # 2.c.ii copy1 = body1.copy(comp1_i, "Copy1") copy3 = body3.copy(comp3_i, "Copy3") - copy1.unite(copy3) + with pytest.raises(ValueError): + copy1.unite(copy3) - assert not copy3.is_alive + assert copy3.is_alive assert body3.is_alive assert Accuracy.length_is_equal(copy1.volume.m, 1) @@ -1866,19 +1921,26 @@ def test_bool_operations_with_keep_other(modeler: Modeler): assert len(comp3.bodies) == 1 # ---- Verify unite operation ---- - body1.unite([body2, body3], keep_other=True) + body1.unite([body2, body3]) - assert body2.is_alive - assert body3.is_alive + assert body1.is_alive + assert not body2.is_alive assert len(comp1.bodies) == 1 - assert len(comp2.bodies) == 1 - assert len(comp3.bodies) == 1 + assert len(comp2.bodies) == 0 + assert len(comp3.bodies) == 0 # ---- Verify intersect operation ---- - body1.intersect(body2, keep_other=True) + comp2 = design.add_component("Comp2") + comp3 = design.add_component("Comp3") + body1 = comp1.extrude_sketch("Body1", Sketch().box(Point2D([0, 0]), 1, 1), 1) + body2 = comp2.extrude_sketch("Body2", Sketch().box(Point2D([0.5, 0]), 1, 1), 1) + body3 = comp3.extrude_sketch("Body3", Sketch().box(Point2D([5, 0]), 1, 1), 1) + body1.intersect([body2, body3], keep_other=True) + assert body1.is_alive assert body2.is_alive - assert len(comp1.bodies) == 1 + assert body3.is_alive + assert len(comp1.bodies) == 2 assert len(comp2.bodies) == 1 assert len(comp3.bodies) == 1 @@ -2002,7 +2064,8 @@ def test_get_collision(modeler: Modeler): def test_set_body_name(modeler: Modeler): """Test the setting the name of a body.""" - skip_if_linux(modeler, test_set_body_name.__name__, "set_name") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_set_body_name.__name__, "set_name") design = modeler.create_design("simple_cube") unit = DEFAULT_UNITS.LENGTH @@ -2023,7 +2086,8 @@ def test_set_body_name(modeler: Modeler): def test_set_fill_style(modeler: Modeler): """Test the setting the fill style of a body.""" - skip_if_linux(modeler, test_set_fill_style.__name__, "set_fill_style") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_set_fill_style.__name__, "set_fill_style") design = modeler.create_design("RVE") unit = DEFAULT_UNITS.LENGTH @@ -2045,6 +2109,29 @@ def test_set_fill_style(modeler: Modeler): assert box.fill_style == FillStyle.OPAQUE +def test_body_suppression(modeler: Modeler): + """Test the suppression of a body.""" + + design = modeler.create_design("RVE") + unit = DEFAULT_UNITS.LENGTH + + plane = Plane( + Point3D([1 / 2, 1 / 2, 0.0], unit=unit), + UNITVECTOR3D_X, + UNITVECTOR3D_Y, + ) + + box_plane = Sketch(plane) + box_plane.box(Point2D([0.0, 0.0]), width=1 * unit, height=1 * unit) + box = design.extrude_sketch("Matrix", box_plane, 1 * unit) + + assert box.is_suppressed is False + box.set_suppressed(True) + assert box.is_suppressed is True + box.is_suppressed = False + assert box.is_suppressed is False + + def test_set_body_color(modeler: Modeler): """Test the getting and setting of body color.""" @@ -2196,7 +2283,9 @@ def test_body_mapping(modeler: Modeler): def test_sphere_creation(modeler: Modeler): """Test the creation of a sphere body with a given radius.""" - skip_if_linux(modeler, test_sphere_creation.__name__, "create_sphere") + # Skip test on CoreService + skip_if_core_service(modeler, test_sphere_creation.__name__, "create_sphere") + design = modeler.create_design("Spheretest") center_point = Point3D([10, 10, 10], UNITS.m) radius = Distance(1, UNITS.m) @@ -2208,7 +2297,9 @@ def test_sphere_creation(modeler: Modeler): def test_body_mirror(modeler: Modeler): """Test the mirroring of a body.""" - skip_if_linux(modeler, test_body_mirror.__name__, "mirror") + # Skip test on CoreService + skip_if_core_service(modeler, test_body_mirror.__name__, "mirror") + design = modeler.create_design("Design1") # Create shape with no lines of symmetry in any axis @@ -2414,7 +2505,8 @@ def test_create_body_from_loft_profile(modeler: Modeler): """Test the ``create_body_from_loft_profile()`` method to create a vase shape. """ - skip_if_linux( + # Skip test on CoreService + skip_if_core_service( modeler, test_create_body_from_loft_profile.__name__, "'create_body_from_loft_profile'" ) design_sketch = modeler.create_design("loftprofile") @@ -2530,8 +2622,8 @@ def test_revolve_sketch_fail_invalid_path(modeler: Modeler): def test_component_tree_print(modeler: Modeler): """Test for verifying the tree print for ``Component`` objects.""" - # Skip on Linux - skip_if_linux(modeler, test_component_tree_print.__name__, "create_beam") + # Skip on CoreService + skip_if_core_service(modeler, test_component_tree_print.__name__, "create_beam") def check_list_equality(lines, expected_lines): # By doing "a in b" rather than "a == b", we can check for substrings @@ -2739,8 +2831,34 @@ def test_surface_body_creation(modeler: Modeler): assert body.faces[0].area.m == pytest.approx(39.4784176044 * 2) +def test_design_parameters(modeler: Modeler): + """Test the design parameter's functionality.""" + design = modeler.open_file(FILES_DIR / "blockswithparameters.dsco") + test_parameters = design.parameters + + # Verify the initial parameters + assert len(test_parameters) == 2 + assert test_parameters[0].name == "p1" + assert abs(test_parameters[0].dimension_value - 0.00010872999999999981) < 1e-8 + assert test_parameters[0].dimension_type == ParameterType.DIMENSIONTYPE_AREA + + assert test_parameters[1].name == "p2" + assert abs(test_parameters[1].dimension_value - 0.0002552758322160813) < 1e-8 + assert test_parameters[1].dimension_type == ParameterType.DIMENSIONTYPE_AREA + + # Update the second parameter and verify the status + test_parameters[1].dimension_value = 0.0006 + status = design.set_parameter(test_parameters[1]) + assert status == ParameterUpdateStatus.SUCCESS + + # Attempt to update the first parameter and expect a constrained status + test_parameters[0].dimension_value = 0.0006 + status = design.set_parameter(test_parameters[0]) + assert status == ParameterUpdateStatus.CONSTRAINED_PARAMETERS + + def test_cached_bodies(modeler: Modeler): - """Test verifying that bodies are cached correctly. + """Test that bodies are cached correctly. Whenever a new body is created, modified etc. we should make sure that the cache is updated. """ @@ -2859,3 +2977,72 @@ def test_extrude_sketch_with_cut_request_no_collision(modeler: Modeler): # Verify the volume of the resulting body is exactly the same assert design.bodies[0].volume == volume_box + + +def test_create_surface_body_from_trimmed_curves(modeler: Modeler): + design = modeler.create_design("surface") + + # pill shape + circle1 = Circle(Point3D([0, 0, 0]), 1).trim(Interval(0, np.pi)) + line1 = Line(Point3D([-1, 0, 0]), UnitVector3D([0, -1, 0])).trim(Interval(0, 1)) + circle2 = Circle(Point3D([0, -1, 0]), 1).trim(Interval(np.pi, np.pi * 2)) + line2 = Line(Point3D([1, 0, 0]), UnitVector3D([0, -1, 0])).trim(Interval(0, 1)) + + body = design.create_surface_from_trimmed_curves("body", [circle1, line1, line2, circle2]) + assert body.is_surface + assert body.faces[0].area.m == pytest.approx( + Quantity(2 + np.pi, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + + # create from edges (by getting their trimmed curves) + trimmed_curves_from_edges = [edge.shape for edge in body.edges] + body = design.create_surface_from_trimmed_curves("body2", trimmed_curves_from_edges) + assert body.is_surface + assert body.faces[0].area.m == pytest.approx( + Quantity(2 + np.pi, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + + +def test_shell_body(modeler: Modeler): + """Test shell command.""" + design = modeler.create_design("shell") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + assert base.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 6 + + # shell + success = base.shell_body(0.1) + assert success + assert base.volume.m == pytest.approx(Quantity(0.728, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 12 + + +def test_shell_faces(modeler: Modeler): + """Test shell commands for a single face.""" + design = modeler.create_design("shell") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + assert base.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 6 + + # shell + success = base.remove_faces(base.faces[0], 0.1) + assert success + assert base.volume.m == pytest.approx(Quantity(0.584, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 11 + + +def test_shell_multiple_faces(modeler: Modeler): + """Test shell commands for multiple faces.""" + design = modeler.create_design("shell") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + assert base.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 6 + + # shell + success = base.remove_faces([base.faces[0], base.faces[2]], 0.1) + assert success + assert base.volume.m == pytest.approx(Quantity(0.452, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 10 diff --git a/tests/integration/test_design_export.py b/tests/integration/test_design_export.py index 034f7fca16..70bf4c02d0 100644 --- a/tests/integration/test_design_export.py +++ b/tests/integration/test_design_export.py @@ -30,6 +30,8 @@ from ansys.geometry.core.math import Plane, Point2D, Point3D, UnitVector3D, Vector3D from ansys.geometry.core.sketch import Sketch +from .conftest import skip_if_core_service, skip_if_spaceclaim, skip_if_windows + def _create_demo_design(modeler: Modeler) -> Design: """Create a demo design for the tests.""" @@ -77,6 +79,46 @@ def _create_demo_design(modeler: Modeler) -> Design: return design +def _create_flat_design(modeler: Modeler) -> Design: + """Create a demo design for the tests.""" + modeler.create_design("Demo") + + design_name = "DemoFlatDesign" + design = modeler.create_design(design_name) + + # Create a car + comp1 = design.add_component("A") + wheel1 = design.add_component("Wheel1") + + # Create car base frame + sketch = Sketch().box(Point2D([5, 10]), 10, 20) + design.add_component("Base").extrude_sketch("BaseBody", sketch, 5) + + # Create first wheel + sketch = Sketch(Plane(direction_x=Vector3D([0, 1, 0]), direction_y=Vector3D([0, 0, 1]))) + sketch.circle(Point2D([0, 0]), 5) + wheel1.extrude_sketch("Wheel", sketch, -5) + + # Create 3 other wheels and move them into position + rotation_origin = Point3D([0, 0, 0]) + rotation_direction = UnitVector3D([0, 0, 1]) + + wheel2 = design.add_component("Wheel2", wheel1) + wheel2.modify_placement(Vector3D([0, 20, 0])) + + wheel3 = design.add_component("Wheel3", wheel1) + wheel3.modify_placement(Vector3D([10, 0, 0]), rotation_origin, rotation_direction, np.pi) + + wheel4 = design.add_component("Wheel4", wheel1) + wheel4.modify_placement(Vector3D([10, 20, 0]), rotation_origin, rotation_direction, np.pi) + + # Create top of car - applies to BOTH cars + sketch = Sketch(Plane(Point3D([0, 5, 5]))).box(Point2D([5, 2.5]), 10, 5) + comp1.extrude_sketch("Top", sketch, 5) + + return design + + def _checker_method(comp: Component, comp_ref: Component, precise_check: bool = True) -> None: # Check component features if precise_check: @@ -130,6 +172,52 @@ def test_export_to_scdocx(modeler: Modeler, tmp_path_factory: pytest.TempPathFac _checker_method(design_read, design, True) +def test_export_to_stride(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): + """Test exporting a design to stride format.""" + skip_if_windows(modeler, test_export_to_stride.__name__, "design") # Skip test on SC/DMS + # Create a demo design + design = _create_flat_design(modeler) + + # Define the location and expected file location + location = tmp_path_factory.mktemp("test_export_to_stride") + file_location = location / f"{design.name}.stride" + + # Export to stride + design.export_to_stride(location) + + # Check the exported file + assert file_location.exists() + + # Import the stride + design_read = modeler.open_file(file_location) + + # Check the imported design + _checker_method(design_read, design, False) + + +def test_export_to_disco(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): + """Test exporting a design to dsco format.""" + skip_if_spaceclaim(modeler, test_export_to_disco.__name__, "disco export") + # Create a demo design + design = _create_demo_design(modeler) + + # Define the location and expected file location + location = tmp_path_factory.mktemp("test_export_to_disco") + file_location = location / f"{design.name}.dsco" + + # Export to dsco + design.export_to_disco(location) + + # Check the exported file + assert file_location.exists() + + # Import the dsco + design_read = modeler.open_file(file_location) + + # Check the imported design + _checker_method(design_read, design, True) + + def test_export_to_parasolid_text(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): """Test exporting a design to parasolid text format.""" # Create a demo design @@ -138,7 +226,7 @@ def test_export_to_parasolid_text(modeler: Modeler, tmp_path_factory: pytest.Tem # Define the location and expected file location location = tmp_path_factory.mktemp("test_export_to_parasolid_text") - if modeler.client.backend_type == BackendType.LINUX_SERVICE: + if BackendType.is_core_service(modeler.client.backend_type): file_location = location / f"{design.name}.x_t" else: file_location = location / f"{design.name}.xmt_txt" @@ -161,7 +249,7 @@ def test_export_to_parasolid_binary(modeler: Modeler, tmp_path_factory: pytest.T # Define the location and expected file location location = tmp_path_factory.mktemp("test_export_to_parasolid_binary") - if modeler.client.backend_type == BackendType.LINUX_SERVICE: + if BackendType.is_core_service(modeler.client.backend_type): file_location = location / f"{design.name}.x_b" else: file_location = location / f"{design.name}.xmt_bin" @@ -178,6 +266,7 @@ def test_export_to_parasolid_binary(modeler: Modeler, tmp_path_factory: pytest.T def test_export_to_step(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): """Test exporting a design to STEP format.""" + skip_if_core_service(modeler, test_export_to_step.__name__, "step_export") # Create a demo design design = _create_demo_design(modeler) @@ -191,7 +280,7 @@ def test_export_to_step(modeler: Modeler, tmp_path_factory: pytest.TempPathFacto # Check the exported file assert file_location.exists() - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): # Import the STEP file design_read = modeler.open_file(file_location) @@ -201,6 +290,8 @@ def test_export_to_step(modeler: Modeler, tmp_path_factory: pytest.TempPathFacto def test_export_to_iges(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): """Test exporting a design to IGES format.""" + skip_if_core_service(modeler, test_export_to_iges.__name__, "iges_export") + # Create a demo design design = _create_demo_design(modeler) @@ -220,6 +311,8 @@ def test_export_to_iges(modeler: Modeler, tmp_path_factory: pytest.TempPathFacto def test_export_to_fmd(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): """Test exporting a design to FMD format.""" + skip_if_core_service(modeler, test_export_to_fmd.__name__, "fmd_export") + # Create a demo design design = _create_demo_design(modeler) diff --git a/tests/integration/test_design_import.py b/tests/integration/test_design_import.py index 4bd5dce1b9..6c0233d2f4 100644 --- a/tests/integration/test_design_import.py +++ b/tests/integration/test_design_import.py @@ -34,8 +34,49 @@ from ansys.geometry.core.math import Plane, Point2D, Point3D, UnitVector3D, Vector3D from ansys.geometry.core.misc import UNITS from ansys.geometry.core.sketch import Sketch +from ansys.geometry.core.tools.unsupported import PersistentIdType -from .conftest import FILES_DIR, IMPORT_FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, IMPORT_FILES_DIR + + +def _create_flat_design(modeler: Modeler) -> Design: + """Create a demo design for the tests.""" + modeler.create_design("Demo") + + design_name = "DemoFlatDesign" + design = modeler.create_design(design_name) + + # Create a car + comp1 = design.add_component("A") + wheel1 = design.add_component("Wheel1") + + # Create car base frame + sketch = Sketch().box(Point2D([5, 10]), 10, 20) + design.add_component("Base").extrude_sketch("BaseBody", sketch, 5) + + # Create first wheel + sketch = Sketch(Plane(direction_x=Vector3D([0, 1, 0]), direction_y=Vector3D([0, 0, 1]))) + sketch.circle(Point2D([0, 0]), 5) + wheel1.extrude_sketch("Wheel", sketch, -5) + + # Create 3 other wheels and move them into position + rotation_origin = Point3D([0, 0, 0]) + rotation_direction = UnitVector3D([0, 0, 1]) + + wheel2 = design.add_component("Wheel2", wheel1) + wheel2.modify_placement(Vector3D([0, 20, 0])) + + wheel3 = design.add_component("Wheel3", wheel1) + wheel3.modify_placement(Vector3D([10, 0, 0]), rotation_origin, rotation_direction, np.pi) + + wheel4 = design.add_component("Wheel4", wheel1) + wheel4.modify_placement(Vector3D([10, 20, 0]), rotation_origin, rotation_direction, np.pi) + + # Create top of car - applies to BOTH cars + sketch = Sketch(Plane(Point3D([0, 5, 5]))).box(Point2D([5, 2.5]), 10, 5) + comp1.extrude_sketch("Top", sketch, 5) + + return design def _checker_method(comp: Component, comp_ref: Component, precise_check: bool = True) -> None: @@ -142,12 +183,12 @@ def test_open_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): # Create car base frame sketch = Sketch().box(Point2D([5, 10]), 10, 20) - comp2.add_component("Base").extrude_sketch("BaseBody", sketch, 5) + base_body = comp2.add_component("Base").extrude_sketch("BaseBody", sketch, 5) # Create first wheel sketch = Sketch(Plane(direction_x=Vector3D([0, 1, 0]), direction_y=Vector3D([0, 0, 1]))) sketch.circle(Point2D([0, 0]), 5) - wheel1.extrude_sketch("Wheel", sketch, -5) + wheel_body = wheel1.extrude_sketch("Wheel", sketch, -5) # Create 3 other wheels and move them into position rotation_origin = Point3D([0, 0, 0]) @@ -170,6 +211,33 @@ def test_open_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): sketch = Sketch(Plane(Point3D([0, 5, 5]))).box(Point2D([5, 2.5]), 10, 5) comp1.extrude_sketch("Top", sketch, 5) + if BackendType.is_core_service(modeler.client.backend_type): + modeler.unsupported.set_export_id(base_body.id, PersistentIdType.PRIME_ID, "1") + modeler.unsupported.set_export_id(wheel_body.id, PersistentIdType.PRIME_ID, "2") + + modeler.unsupported.set_export_id(base_body.faces[0].id, PersistentIdType.PRIME_ID, "3") + modeler.unsupported.set_export_id(base_body.edges[0].id, PersistentIdType.PRIME_ID, "4") + + bodies1 = modeler.unsupported.get_body_occurrences_from_import_id( + "1", PersistentIdType.PRIME_ID + ) + bodies2 = modeler.unsupported.get_body_occurrences_from_import_id( + "2", PersistentIdType.PRIME_ID + ) + + assert base_body.id in [b.id for b in bodies1] + assert wheel_body.id in [b.id for b in bodies2] + + faces = modeler.unsupported.get_face_occurrences_from_import_id( + "3", PersistentIdType.PRIME_ID + ) + edges = modeler.unsupported.get_edge_occurrences_from_import_id( + "4", PersistentIdType.PRIME_ID + ) + + assert base_body.faces[0].id in [f.id for f in faces] + assert base_body.edges[0].id in [e.id for e in edges] + file = tmp_path_factory.mktemp("test_design_import") / "two_cars.scdocx" design.download(str(file)) @@ -179,7 +247,7 @@ def test_open_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): _checker_method(design, design2, True) # Test HOOPS formats (Windows only) - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): # IGES # # TODO: Something has gone wrong with IGES @@ -223,12 +291,21 @@ def test_open_file(modeler: Modeler, tmp_path_factory: pytest.TempPathFactory): design2 = modeler.open_file(Path(IMPORT_FILES_DIR, "disk1.prt")) assert len(design2.bodies) == 1 + if modeler.client.backend_type not in ( + BackendType.SPACECLAIM, + BackendType.WINDOWS_SERVICE, + BackendType.DISCOVERY, + ): + design_stride = _create_flat_design(modeler) + file = tmp_path_factory.mktemp("test_design_import") / "two_cars.stride" + design_stride.download(file, DesignFileFormat.STRIDE) + design2 = modeler.open_file(file) + design3 = modeler.open_file(Path(IMPORT_FILES_DIR, "twoCars.stride")) + _checker_method(design2, design3, False) + def test_design_insert(modeler: Modeler): """Test inserting a file into the design.""" - # Skip for Linux service - skip_if_linux(modeler, test_design_insert.__name__, "insert_file") - # Create a design and sketch a circle design = modeler.create_design("Insert") sketch = Sketch() @@ -241,6 +318,7 @@ def test_design_insert(modeler: Modeler): # Check that there are two components assert len(design.components) == 2 + assert design._is_active assert design.components[0].name == "Component_Cylinder" assert design.components[1].name == "DuplicatesDesign" @@ -249,9 +327,6 @@ def test_design_insert_with_import(modeler: Modeler): """Test inserting a file into the design through the external format import process. """ - # Skip for Linux service - skip_if_linux(modeler, test_design_insert_with_import.__name__, "insert_file") - # Create a design and sketch a circle design = modeler.create_design("Insert") sketch = Sketch() @@ -264,5 +339,6 @@ def test_design_insert_with_import(modeler: Modeler): # Check that there are two components assert len(design.components) == 2 + assert design._is_active assert design.components[0].name == "Component_Cylinder" assert design.components[1].name == "Wheel1" diff --git a/tests/integration/test_geometry_commands.py b/tests/integration/test_geometry_commands.py new file mode 100644 index 0000000000..096c81cadc --- /dev/null +++ b/tests/integration/test_geometry_commands.py @@ -0,0 +1,758 @@ +# Copyright (C) 2023 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +"""Testing of geometry commands.""" + +import numpy as np +from pint import Quantity +import pytest + +from ansys.geometry.core.designer.geometry_commands import ( + ExtrudeType, + FillPatternType, + OffsetMode, +) +from ansys.geometry.core.math import Plane, Point2D, Point3D, UnitVector3D +from ansys.geometry.core.misc import UNITS +from ansys.geometry.core.modeler import Modeler +from ansys.geometry.core.shapes.curves.line import Line +from ansys.geometry.core.sketch.sketch import Sketch + +from .conftest import FILES_DIR, skip_if_core_service + + +def test_chamfer(modeler: Modeler): + """Test chamfer on edges and faces.""" + design = modeler.create_design("chamfer") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.chamfer(body.edges[0], 0.1) + assert len(body.faces) == 7 + assert len(body.edges) == 15 + assert body.volume.m == pytest.approx(Quantity(0.995, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.chamfer(body.faces[-1], 0.5) + assert len(body.faces) == 7 + assert len(body.edges) == 15 + assert body.volume.m == pytest.approx(Quantity(0.875, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # multiple edges + body2 = design.extrude_sketch("box2", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body2.faces) == 6 + assert len(body2.edges) == 12 + assert body2.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.chamfer(body2.edges, 0.1) + assert len(body2.faces) == 26 + assert len(body2.edges) == 48 + assert body2.volume.m == pytest.approx( + Quantity(0.945333333333333333, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + +def test_fillet(modeler: Modeler): + """Test fillet on edge and face.""" + design = modeler.create_design("fillet") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.fillet(body.edges[0], 0.1) + assert len(body.faces) == 7 + assert len(body.edges) == 15 + assert body.volume.m == pytest.approx( + Quantity(0.9978539816339744, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + modeler.geometry_commands.fillet(body.faces[-1], 0.5) + assert len(body.faces) == 7 + assert len(body.edges) == 15 + assert body.volume.m == pytest.approx( + Quantity(0.946349540849362, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + # multiple edges + body2 = design.extrude_sketch("box2", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body2.faces) == 6 + assert len(body2.edges) == 12 + assert body2.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.fillet(body2.edges, 0.1) + assert len(body2.faces) == 26 + assert len(body2.edges) == 48 + assert body2.volume.m == pytest.approx( + Quantity(0.9755870138909422, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + modeler.geometry_commands.fillet(body2.faces, 0.3) + assert len(body2.faces) == 26 + assert len(body2.edges) == 48 + assert body2.volume.m == pytest.approx( + Quantity(0.8043893421169303, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + modeler.geometry_commands.fillet(body2.faces, 0.05) + assert len(body2.faces) == 26 + assert len(body2.edges) == 48 + assert body2.volume.m == pytest.approx( + Quantity(0.9937293491873294, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + +def test_full_fillet(modeler: Modeler): + """Test full fillet on faces.""" + design = modeler.create_design("full_fillet") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + modeler.geometry_commands.full_fillet(body.faces[0:3]) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx( + Quantity(0.8926990816987, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + +def test_extrude_faces_and_offset_relationships(modeler: Modeler): + """Test extrude faces and offset relationships.""" + design = modeler.create_design("extrude_faces") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # extrude out, double volume + modeler.geometry_commands.extrude_faces(body.faces[1], 1) + assert body.volume.m == pytest.approx(Quantity(2, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # extrude in, cut in half + modeler.geometry_commands.extrude_faces( + body.faces[1], + -1, + None, + ExtrudeType.FORCE_CUT, + OffsetMode.IGNORE_RELATIONSHIPS, + False, + False, + True, + ) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # setup offset relationship, faces will move together, volume won't change + body.faces[0].setup_offset_relationship(body.faces[1], True) + modeler.geometry_commands.extrude_faces( + body.faces[1], + 1, + None, + ExtrudeType.ADD, + OffsetMode.MOVE_FACES_TOGETHER, + False, + False, + False, + ) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # move apart, volume should double + modeler.geometry_commands.extrude_faces( + body.faces[1], + 0.5, + None, + ExtrudeType.ADD, + OffsetMode.MOVE_FACES_APART, + False, + False, + False, + ) + assert body.volume.m == pytest.approx(Quantity(2, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + +def test_extrude_faces_up_to(modeler: Modeler): + """Test extrude faces up to.""" + design = modeler.create_design("extrude_faces_up_to") + up_to = design.extrude_sketch("up_to", Sketch().box(Point2D([0, 0]), 1, 1), 1) + up_to.translate(UnitVector3D([0, 0, 1]), 4) + assert up_to.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # extrude up to other block's face, force independent or else they would merge into one body + bodies = modeler.geometry_commands.extrude_faces_up_to( + body.faces[1], + up_to.faces[0], + Point3D([0, 0, 0]), + UnitVector3D([0, 0, 1]), + ExtrudeType.FORCE_INDEPENDENT, + OffsetMode.IGNORE_RELATIONSHIPS, + ) + assert len(bodies) == 0 + assert len(design.bodies) == 2 + assert body.volume.m == pytest.approx(Quantity(4, UNITS.m**3).m, rel=1e-6, abs=1e-8) + body.parent_component.delete_body(body) + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # extrude up to other block's edge, add so they merge into one body + bodies = modeler.geometry_commands.extrude_faces_up_to( + body.faces[1], + up_to.edges[0], + Point3D([0, 0, 0]), + UnitVector3D([0, 0, 1]), + ExtrudeType.ADD, + ) + assert len(bodies) == 0 + assert len(design.bodies) == 1 + assert body.volume.m == pytest.approx(Quantity(5, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + +def test_extrude_edges_and_up_to(modeler: Modeler): + """Test extrude edges and up to.""" + design = modeler.create_design("extrude_edges") + upto = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + upto.translate(UnitVector3D([0, 0, 1]), 5) + assert upto.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + body = design.extrude_sketch("box2", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + # extrude edge + created_bodies = modeler.geometry_commands.extrude_edges( + body.edges[0], 1, body.edges[0].faces[1] + ) + assert len(created_bodies) == 1 + assert created_bodies[0].is_surface + assert created_bodies[0].faces[0].area.m == pytest.approx( + Quantity(1, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + + # extrude edge up to face + created_bodies = modeler.geometry_commands.extrude_edges_up_to( + body.edges[0], upto.faces[0], Point3D([0, 0, 0]), UnitVector3D([0, 0, 1]) + ) + assert len(created_bodies) == 1 + assert created_bodies[0].is_surface + assert created_bodies[0].faces[0].area.m == pytest.approx( + Quantity(4, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + + # extrude multiple edges up to + created_bodies = modeler.geometry_commands.extrude_edges_up_to( + body.edges, upto.faces[1], Point3D([0, 0, 0]), UnitVector3D([0, 0, 1]) + ) + assert created_bodies[0].is_surface + assert created_bodies[0].faces[0].area.m == pytest.approx( + Quantity(6, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + assert created_bodies[0].faces[1].area.m == pytest.approx( + Quantity(6, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + assert created_bodies[0].faces[2].area.m == pytest.approx( + Quantity(6, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + assert created_bodies[0].faces[3].area.m == pytest.approx( + Quantity(6, UNITS.m**2).m, rel=1e-6, abs=1e-8 + ) + + +def test_rename_body_object(modeler: Modeler): + """Test renaming body objects.""" + design = modeler.create_design("rename") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + selection = [body] + result = modeler.geometry_commands.rename_object(selection, "new_name") + design._update_design_inplace() + + body = design.bodies[0] + assert result + assert body.name == "new_name" + + result = modeler.geometry_commands.rename_object(selection, "new_name2") + design._update_design_inplace() + + body = design.bodies[0] + assert result + assert body.name == "new_name2" + + +def test_rename_component_object(modeler: Modeler): + """Test renaming component objects.""" + design = modeler.create_design("rename_component") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + + selection = [body.parent_component] + result = modeler.geometry_commands.rename_object(selection, "new_name") + design._update_design_inplace() + + component = body.parent_component + assert result + assert component.name == "new_name" + + result = modeler.geometry_commands.rename_object(selection, "new_name2") + design._update_design_inplace() + + component = body.parent_component + assert result + assert component.name == "new_name2" + + +def test_linear_pattern(modeler: Modeler): + """Test linear pattern.""" + design = modeler.create_design("linear_pattern") + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + body.subtract(cutout) + + # two dimensional + success = modeler.geometry_commands.create_linear_pattern( + body.faces[-1], body.edges[2], 5, 0.2, True, 5, 0.2 + ) + assert success + assert body.volume.m == pytest.approx( + Quantity(0.803650459151, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(body.faces) == 31 + + # modify linear pattern + success = modeler.geometry_commands.modify_linear_pattern(body.faces[-1], 8, 0.11, 8, 0.11) + assert success + assert body.volume.m == pytest.approx( + Quantity(0.497345175426, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(body.faces) == 70 + + # try keeping some old values + success = modeler.geometry_commands.modify_linear_pattern(body.faces[-1], 4, 0, 4, 0, 1, 1) + assert success + assert body.volume.m == pytest.approx( + Quantity(0.874336293856, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(body.faces) == 22 + + # back to creating - one dimensional + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + body.subtract(cutout) + + success = modeler.geometry_commands.create_linear_pattern(body.faces[-1], body.edges[2], 5, 0.2) + assert success + assert body.volume.m == pytest.approx(Quantity(0.96073009183, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(body.faces) == 11 + + # intentional failure to create pattern + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + body.subtract(cutout) + + success = modeler.geometry_commands.create_linear_pattern(body.faces[-1], body.edges[0], 5, 0.2) + assert not success + assert body.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(body.faces) == 7 + + # input validation test + with pytest.raises( + ValueError, + match="If the pattern is two dimensional, count_y and pitch_y must be provided.", + ): + modeler.geometry_commands.create_linear_pattern(body.faces[-1], body.edges[0], 5, 0.2, True) + with pytest.raises( + ValueError, + match="If the pattern is two dimensional, count_y and pitch_y must be provided.", + ): + modeler.geometry_commands.create_linear_pattern( + body.faces[-1], body.edges[0], 5, 0.2, True, 5 + ) + with pytest.raises( + ValueError, + match=( + "You provided count_y and pitch_y. Ensure two_dimensional is True if a " + "two-dimensional pattern is desired." + ), + ): + modeler.geometry_commands.create_linear_pattern( + body.faces[-1], body.edges[0], 5, 0.2, False, 5, 0.2 + ) + + +def test_circular_pattern(modeler: Modeler): + """Test circular pattern.""" + design = modeler.create_design("d1") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + axis = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 0.01, 0.01), 1) + base.subtract(axis) + axis = base.edges[20] + + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.2, 0]), 0.005), 1) + base.subtract(cutout) + + assert base.volume.m == pytest.approx( + Quantity(0.999821460184, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 11 + + # full two-dimensional test - creates 3 rings around the center + success = modeler.geometry_commands.create_circular_pattern( + base.faces[-1], axis, 12, np.pi * 2, True, 3, 0.05, UnitVector3D([1, 0, 0]) + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.997072566612, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 46 + + # input validation test + with pytest.raises( + ValueError, + match="If the pattern is two-dimensional, linear_count and linear_pitch must be provided.", + ): + modeler.geometry_commands.create_circular_pattern(base.faces[-1], axis, 12, np.pi * 2, True) + with pytest.raises( + ValueError, + match=( + "You provided linear_count and linear_pitch. Ensure two_dimensional is True if a " + "two-dimensional pattern is desired." + ), + ): + modeler.geometry_commands.create_circular_pattern( + base.faces[-1], axis, 12, np.pi * 2, False, 3, 0.05 + ) + + +def test_fill_pattern(modeler: Modeler): + """Test fill pattern.""" + design = modeler.create_design("d1") + + # grid fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.GRID, + 0.01, + 0.1, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.803650459151, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 31 + + # offset fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.OFFSET, + 0.01, + 0.05, + 0.05, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.670132771373, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 48 + + # skewed fill pattern + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.SKEWED, + 0.01, + 0.1, + 0.1, + 0.1, + 0.2, + 0.2, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.787942495883, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 33 + + # update fill pattern + base = design.extrude_sketch("update_fill", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + base.translate(UnitVector3D([1, 0, 0]), 5) + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + success = modeler.geometry_commands.create_fill_pattern( + base.faces[-1], + base.edges[2], + FillPatternType.GRID, + 0.01, + 0.1, + 0.1, + ) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.803650459151, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 31 + + face = base.faces[3] + modeler.geometry_commands.extrude_faces(face, 1, face.normal(0, 0)) + success = modeler.geometry_commands.update_fill_pattern(base.faces[-1]) + assert success + assert base.volume.m == pytest.approx(Quantity(1.60730091830, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 56 + + +def test_revolve_faces(modeler: Modeler): + """Test revolve faces.""" + design = modeler.create_design("revolve_faces") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + bodies = modeler.geometry_commands.revolve_faces( + base.faces[2], Line([0.5, 0.5, 0], [0, 0, 1]), np.pi * 3 / 2 + ) + assert len(bodies) == 0 + assert base.volume.m == pytest.approx(Quantity(3.35619449019, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 5 + + +def test_revolve_faces_up_to(modeler: Modeler): + """Test revolve faces up to.""" + design = modeler.create_design("revolve_faces_up_to") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + bodies = modeler.geometry_commands.revolve_faces_up_to( + base.faces[2], + base.faces[4], + Line([0.5, 0.5, 0], [0, 0, 1]), + UnitVector3D([1, 0, 0]), + ExtrudeType.FORCE_ADD, + ) + assert len(bodies) == 0 + assert base.volume.m == pytest.approx(Quantity(1.78539816340, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 6 + + +def test_revolve_faces_by_helix(modeler: Modeler): + """Test revolve faces by helix.""" + design = modeler.create_design("revolve_faces_by_helix") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + bodies = modeler.geometry_commands.revolve_faces_by_helix( + base.faces[2], + Line([0.5, 0.5, 0], [0, 0, 1]), + UnitVector3D([1, 0, 0]), + 5, + 1, + np.pi / 4, + True, + True, + ) + assert len(bodies) == 2 + assert base.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + assert len(base.faces) == 6 + + assert design.bodies[1].volume.m == pytest.approx( + Quantity(86.2510674259, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 6 + # raise tolerance to 1e-4 to account for windows/linux parasolid differences + assert design.bodies[2].volume.m == pytest.approx( + Quantity(86.2510735368, UNITS.m**3).m, rel=1e-4, abs=1e-8 + ) + assert len(base.faces) == 6 + + +def test_replace_face(modeler: Modeler): + """Test replacing a face with another face.""" + design = modeler.create_design("replace_face") + base = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + cutout = design.extrude_sketch("cylinder", Sketch().circle(Point2D([-0.4, -0.4]), 0.05), 1) + base.subtract(cutout) + + # replace face with a new face + new_face = design.extrude_sketch("new_face", Sketch().box(Point2D([0, 0]), 0.1, 0.1), 1) + success = modeler.geometry_commands.replace_face(base.faces[-1], new_face.faces[0]) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + # replace face with an existing face + success = modeler.geometry_commands.replace_face(base.faces[-1], base.faces[0]) + assert success + assert base.volume.m == pytest.approx( + Quantity(0.992146018366, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert len(base.faces) == 7 + + +def test_split_body_by_plane(modeler: Modeler): + """Test split body by plane""" + design = modeler.create_design("split_body_by_plane") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + origin = Point3D([0, 0, 0.5]) + plane = Plane(origin, direction_x=[1, 0, 0], direction_y=[0, 1, 0]) + + success = modeler.geometry_commands.split_body([body], plane, None, None, True) + assert success is True + + assert len(design.bodies) == 2 + + assert design.bodies[0].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert design.bodies[1].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + +def test_split_body_by_slicer_face(modeler: Modeler): + """Test split body by slicer face""" + design = modeler.create_design("split_body_by_slicer_face") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + body2 = design.extrude_sketch("box2", Sketch().box(Point2D([3, 0]), 1, 1), 0.5) + assert len(body2.faces) == 6 + assert len(body2.edges) == 12 + assert body2.volume.m == pytest.approx(Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + face_to_split = body2.faces[1] + + success = modeler.geometry_commands.split_body([body], None, [face_to_split], None, True) + assert success is True + + assert len(design.bodies) == 3 + + assert design.bodies[0].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert design.bodies[1].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert design.bodies[2].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + + +def test_split_body_by_slicer_edge(modeler: Modeler): + """Test split body by slicer edge""" + # Skip for Core service + skip_if_core_service( + modeler, test_split_body_by_slicer_edge.__name__, "split_body_by_slicer_edge" + ) + + design = modeler.open_file(FILES_DIR / "Edge_Slice_test.dsco") + + assert len(design.bodies) == 1 + body = design.bodies[0] + assert len(body.faces) == 4 + assert len(body.edges) == 3 + assert body.volume.m == pytest.approx( + Quantity(6.283185307179587e-06, UNITS.m**3).m, rel=1e-5, abs=1e-8 + ) + + edge_to_split = body.edges[2] + + success = modeler.geometry_commands.split_body([body], None, [edge_to_split], None, True) + assert success is True + + assert len(design.bodies) == 2 + + assert design.bodies[0].volume.m == pytest.approx( + Quantity(3.1415927e-06, UNITS.m**3).m, rel=1e-5, abs=1e-8 + ) + assert design.bodies[1].volume.m == pytest.approx( + Quantity(3.1415927e-06, UNITS.m**3).m, rel=1e-5, abs=1e-8 + ) + + +def test_split_body_by_face(modeler: Modeler): + """Test split body by face""" + design = modeler.create_design("split_body_by_face") + + body = design.extrude_sketch("box", Sketch().box(Point2D([0, 0]), 1, 1), 1) + assert len(body.faces) == 6 + assert len(body.edges) == 12 + assert body.volume.m == pytest.approx(Quantity(1, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + body2 = design.extrude_sketch("box2", Sketch().box(Point2D([3, 0]), 1, 1), 0.5) + assert len(body2.faces) == 6 + assert len(body2.edges) == 12 + assert body2.volume.m == pytest.approx(Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8) + + face_to_split = body2.faces[1] + + success = modeler.geometry_commands.split_body([body], None, None, [face_to_split], True) + assert success is True + + assert len(design.bodies) == 3 + + assert design.bodies[0].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert design.bodies[1].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) + assert design.bodies[2].volume.m == pytest.approx( + Quantity(0.5, UNITS.m**3).m, rel=1e-6, abs=1e-8 + ) diff --git a/tests/integration/test_issues.py b/tests/integration/test_issues.py index 4988dba12e..f1a3b4ce3e 100644 --- a/tests/integration/test_issues.py +++ b/tests/integration/test_issues.py @@ -37,7 +37,7 @@ from ansys.geometry.core.modeler import Modeler from ansys.geometry.core.sketch import Sketch -from .conftest import FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, skip_if_core_service def test_issue_834_design_import_with_surfaces(modeler: Modeler): @@ -48,7 +48,7 @@ def test_issue_834_design_import_with_surfaces(modeler: Modeler): """ # TODO: to be reactivated # https://github.com/ansys/pyansys-geometry/issues/799 - skip_if_linux(modeler, test_issue_834_design_import_with_surfaces.__name__, "open_file") + skip_if_core_service(modeler, test_issue_834_design_import_with_surfaces.__name__, "open_file") # Open the design design = modeler.open_file(Path(FILES_DIR, "DuplicateFacesDesignBefore.scdocx")) @@ -86,8 +86,8 @@ def test_issue_1184_sphere_creation_crashes(modeler: Modeler): For more info see https://github.com/ansys/pyansys-geometry/issues/1184 """ - # Skip this test on Linux since it is not implemented yet - skip_if_linux(modeler, test_issue_1184_sphere_creation_crashes.__name__, "create_sphere") + # Skip this test on CoreService since it is not implemented yet + skip_if_core_service(modeler, test_issue_1184_sphere_creation_crashes.__name__, "create_sphere") design = modeler.create_design("SphereCreationIssue") diff --git a/tests/integration/test_measurement_tools.py b/tests/integration/test_measurement_tools.py index 73f9e3e877..ec193d3ff1 100644 --- a/tests/integration/test_measurement_tools.py +++ b/tests/integration/test_measurement_tools.py @@ -25,7 +25,7 @@ from ansys.geometry.core.modeler import Modeler from ansys.geometry.core.tools.measurement_tools import Gap -from .conftest import FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, skip_if_core_service def test_distance_property(modeler: Modeler): @@ -36,9 +36,9 @@ def test_distance_property(modeler: Modeler): def test_min_distance_between_objects(modeler: Modeler): """Test if split edge problem areas are detectable.""" - skip_if_linux( + skip_if_core_service( modeler, test_min_distance_between_objects.__name__, "measurement_tools" - ) # Skip test on Linux + ) # Skip test on CoreService design = modeler.open_file(FILES_DIR / "MixingTank.scdocx") gap = modeler.measurement_tools.min_distance_between_objects(design.bodies[2], design.bodies[1]) assert abs(gap.distance._value - 0.0892) <= 0.01 diff --git a/tests/integration/test_prepare_tools.py b/tests/integration/test_prepare_tools.py index de7a9f04c5..1aa4622542 100644 --- a/tests/integration/test_prepare_tools.py +++ b/tests/integration/test_prepare_tools.py @@ -23,14 +23,11 @@ from ansys.geometry.core.modeler import Modeler -from .conftest import FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, skip_if_core_service def test_volume_extract_from_faces(modeler: Modeler): """Test a volume is created from the provided faces.""" - skip_if_linux( - modeler, test_volume_extract_from_faces.__name__, "prepare_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "hollowCylinder.scdocx") body = design.bodies[0] @@ -43,9 +40,6 @@ def test_volume_extract_from_faces(modeler: Modeler): def test_volume_extract_from_edge_loops(modeler: Modeler): """Test a volume is created from the provided edges.""" - skip_if_linux( - modeler, test_volume_extract_from_edge_loops.__name__, "prepare_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "hollowCylinder.scdocx") body = design.bodies[0] @@ -59,7 +53,8 @@ def test_volume_extract_from_edge_loops(modeler: Modeler): def test_share_topology(modeler: Modeler): """Test share topology operation is between two bodies.""" - skip_if_linux(modeler, test_share_topology.__name__, "prepare_tools") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_share_topology.__name__, "prepare_tools") design = modeler.open_file(FILES_DIR / "MixingTank.scdocx") assert modeler.prepare_tools.share_topology(design.bodies) diff --git a/tests/integration/test_repair_tools.py b/tests/integration/test_repair_tools.py index 4ea38aec08..a61e3653c5 100644 --- a/tests/integration/test_repair_tools.py +++ b/tests/integration/test_repair_tools.py @@ -21,16 +21,13 @@ # SOFTWARE. """ "Testing of repair tools.""" -import pytest - from ansys.geometry.core.modeler import Modeler -from .conftest import FILES_DIR, skip_if_linux +from .conftest import FILES_DIR, skip_if_core_service def test_find_split_edges(modeler: Modeler): """Test if split edge problem areas are detectable.""" - skip_if_linux(modeler, test_find_split_edges.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "SplitEdgeDesignTest.scdocx") problem_areas = modeler.repair_tools.find_split_edges(design.bodies, 25, 150) assert len(problem_areas) == 3 @@ -38,7 +35,6 @@ def test_find_split_edges(modeler: Modeler): def test_find_split_edge_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_split_edge_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "SplitEdgeDesignTest.scdocx") problem_areas = modeler.repair_tools.find_split_edges(design.bodies, 25, 150) assert problem_areas[0].id != "0" @@ -46,9 +42,6 @@ def test_find_split_edge_id(modeler: Modeler): def test_find_split_edge_edges(modeler: Modeler): """Test to find split edge problem areas with the connected edges.""" - skip_if_linux( - modeler, test_find_split_edge_edges.__name__, "repair_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "SplitEdgeDesignTest.scdocx") problem_areas = modeler.repair_tools.find_split_edges(design.bodies, 25, 150) assert len(problem_areas[0].edges) > 0 @@ -56,7 +49,6 @@ def test_find_split_edge_edges(modeler: Modeler): def test_fix_split_edge(modeler: Modeler): """Test to find and fix split edge problem areas.""" - skip_if_linux(modeler, test_fix_split_edge.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "SplitEdgeDesignTest.scdocx") problem_areas = modeler.repair_tools.find_split_edges(design.bodies, 25, 150) assert problem_areas[0].fix().success is True @@ -64,7 +56,6 @@ def test_fix_split_edge(modeler: Modeler): def test_find_extra_edges(modeler: Modeler): """Test to read geometry and find it's extra edge problem areas.""" - skip_if_linux(modeler, test_find_extra_edges.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "ExtraEdgesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_extra_edges(design.bodies) @@ -73,7 +64,6 @@ def test_find_extra_edges(modeler: Modeler): def test_find_extra_edge_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_extra_edge_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "ExtraEdgesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_extra_edges(design.bodies) assert problem_areas[0].id != "0" @@ -83,18 +73,13 @@ def test_find_extra_edge_edges(modeler: Modeler): """Test to read geometry and find it's extra edge problem area with connected edges. """ - skip_if_linux( - modeler, test_find_extra_edge_edges.__name__, "repair_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "ExtraEdgesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_extra_edges(design.bodies) assert len(problem_areas[0].edges) > 0 -@pytest.mark.skip(reason="This test is failing on the Geometry Service - issue 1335") def test_fix_extra_edge(modeler: Modeler): """Test to find and fix extra edge problem areas.""" - skip_if_linux(modeler, test_fix_extra_edge.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "ExtraEdgesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_extra_edges(design.bodies) assert problem_areas[0].fix().success is True @@ -102,7 +87,6 @@ def test_fix_extra_edge(modeler: Modeler): def test_find_inexact_edges(modeler: Modeler): """Test to read geometry and find it's inexact edge problem areas.""" - skip_if_linux(modeler, test_find_inexact_edges.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "InExactEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_inexact_edges(design.bodies) assert len(problem_areas) == 12 @@ -110,7 +94,6 @@ def test_find_inexact_edges(modeler: Modeler): def test_find_inexact_edge_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_inexact_edge_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "InExactEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_inexact_edges(design.bodies) assert problem_areas[0].id != "0" @@ -120,9 +103,6 @@ def test_find_inexact_edge_edges(modeler: Modeler): """Test to read geometry and find it's inexact edge problem areas with connected edges. """ - skip_if_linux( - modeler, test_find_inexact_edge_edges.__name__, "repair_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "InExactEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_inexact_edges(design.bodies) assert len(problem_areas[0].edges) > 0 @@ -132,7 +112,6 @@ def test_fix_inexact_edge(modeler: Modeler): """Test to read geometry and find and fix it's inexact edge problem areas. """ - skip_if_linux(modeler, test_fix_inexact_edge.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "InExactEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_inexact_edges(design.bodies) assert problem_areas[0].fix().success is True @@ -140,7 +119,6 @@ def test_fix_inexact_edge(modeler: Modeler): def test_find_missing_faces(modeler: Modeler): """Test to read geometry and find it's missing face problem areas.""" - skip_if_linux(modeler, test_find_missing_faces.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "MissingFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_missing_faces(design.bodies) assert len(problem_areas) == 1 @@ -148,7 +126,6 @@ def test_find_missing_faces(modeler: Modeler): def test_find_missing_face_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_missing_face_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "MissingFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_missing_faces(design.bodies) assert problem_areas[0].id != "0" @@ -158,9 +135,6 @@ def test_find_missing_face_faces(modeler: Modeler): """Test to read geometry and find it's missing face problem area with connected edges. """ - skip_if_linux( - modeler, test_find_missing_face_faces.__name__, "repair_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "MissingFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_missing_faces(design.bodies) assert len(problem_areas[0].edges) > 0 @@ -170,7 +144,6 @@ def test_fix_missing_face(modeler: Modeler): """Test to read geometry and find and fix it's missing face problem areas. """ - skip_if_linux(modeler, test_fix_missing_face.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "MissingFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_missing_faces(design.bodies) assert problem_areas[0].fix().success is True @@ -178,7 +151,6 @@ def test_fix_missing_face(modeler: Modeler): def test_find_duplicate_faces(modeler: Modeler): """Test to read geometry and find it's duplicate face problem areas.""" - skip_if_linux(modeler, test_find_duplicate_faces.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "DuplicateFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_duplicate_faces(design.bodies) assert len(problem_areas) == 1 @@ -186,7 +158,6 @@ def test_find_duplicate_faces(modeler: Modeler): def test_duplicate_face_id(modeler: Modeler): """Test whether duplicate face problem area has the id.""" - skip_if_linux(modeler, test_duplicate_face_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "DuplicateFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_duplicate_faces(design.bodies) assert problem_areas[0].id != "0" @@ -196,7 +167,6 @@ def test_duplicate_face_faces(modeler: Modeler): """Test to read geometry and find it's duplicate face problem area and its connected faces. """ - skip_if_linux(modeler, test_duplicate_face_faces.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "DuplicateFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_duplicate_faces(design.bodies) assert len(problem_areas[0].faces) > 0 @@ -206,7 +176,6 @@ def test_fix_duplicate_face(modeler: Modeler): """Test to read geometry and find and fix it's duplicate face problem areas. """ - skip_if_linux(modeler, test_fix_duplicate_face.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "DuplicateFacesDesignBefore.scdocx") problem_areas = modeler.repair_tools.find_duplicate_faces(design.bodies) assert problem_areas[0].fix().success is True @@ -214,7 +183,8 @@ def test_fix_duplicate_face(modeler: Modeler): def test_find_small_faces(modeler: Modeler): """Test to read geometry and find it's small face problem areas.""" - skip_if_linux(modeler, test_find_small_faces.__name__, "repair_tools") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_find_small_faces.__name__, "repair_tools") design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert len(problem_areas) == 4 @@ -222,7 +192,8 @@ def test_find_small_faces(modeler: Modeler): def test_find_small_face_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_small_face_id.__name__, "repair_tools") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_find_small_face_id.__name__, "repair_tools") design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert problem_areas[0].id != "0" @@ -232,9 +203,9 @@ def test_find_small_face_faces(modeler: Modeler): """Test to read geometry, find it's small face problem area and return connected faces. """ - skip_if_linux( + skip_if_core_service( modeler, test_find_small_face_faces.__name__, "repair_tools" - ) # Skip test on Linux + ) # Skip test on CoreService design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert len(problem_areas[0].faces) > 0 @@ -242,7 +213,8 @@ def test_find_small_face_faces(modeler: Modeler): def test_fix_small_face(modeler: Modeler): """Test to read geometry and find and fix it's small face problem areas.""" - skip_if_linux(modeler, test_fix_small_face.__name__, "repair_tools") # Skip test on Linux + # Skip test on CoreService + skip_if_core_service(modeler, test_fix_small_face.__name__, "repair_tools") design = modeler.open_file(FILES_DIR / "SmallFacesBefore.scdocx") problem_areas = modeler.repair_tools.find_small_faces(design.bodies) assert problem_areas[0].fix().success is True @@ -250,7 +222,6 @@ def test_fix_small_face(modeler: Modeler): def test_find_stitch_faces(modeler: Modeler): """Test to read geometry and find it's stitch face problem areas.""" - skip_if_linux(modeler, test_find_stitch_faces.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "stitch_before.scdocx") problem_areas = modeler.repair_tools.find_stitch_faces(design.bodies) assert len(problem_areas) == 1 @@ -258,7 +229,6 @@ def test_find_stitch_faces(modeler: Modeler): def test_find_stitch_face_id(modeler: Modeler): """Test whether problem area has the id.""" - skip_if_linux(modeler, test_find_stitch_face_id.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "stitch_before.scdocx") problem_areas = modeler.repair_tools.find_stitch_faces(design.bodies) assert problem_areas[0].id != "0" @@ -268,9 +238,6 @@ def test_find_stitch_face_bodies(modeler: Modeler): """Test to read geometry and find it's stitch face problem area and return the connected faces. """ - skip_if_linux( - modeler, test_find_stitch_face_bodies.__name__, "repair_tools" - ) # Skip test on Linux design = modeler.open_file(FILES_DIR / "stitch_before.scdocx") problem_areas = modeler.repair_tools.find_stitch_faces(design.bodies) assert len(problem_areas[0].bodies) > 0 @@ -280,7 +247,6 @@ def test_fix_stitch_face(modeler: Modeler): """Test to read geometry, find the split edge problem areas and to fix them. """ - skip_if_linux(modeler, test_fix_stitch_face.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "stitch_before.scdocx") problem_areas = modeler.repair_tools.find_stitch_faces(design.bodies) message = problem_areas[0].fix() @@ -291,7 +257,6 @@ def test_fix_stitch_face(modeler: Modeler): def test_find_short_edges(modeler: Modeler): """Test to read geometry and find it's short edge problem areas.""" - skip_if_linux(modeler, test_find_short_edges.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "ShortEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_short_edges(design.bodies, 10) assert len(problem_areas) == 12 @@ -299,7 +264,192 @@ def test_find_short_edges(modeler: Modeler): def test_fix_short_edges(modeler: Modeler): """Test to read geometry and find and fix it's short edge problem areas.""" - skip_if_linux(modeler, test_fix_short_edges.__name__, "repair_tools") # Skip test on Linux design = modeler.open_file(FILES_DIR / "ShortEdgesBefore.scdocx") problem_areas = modeler.repair_tools.find_short_edges(design.bodies, 10) assert problem_areas[0].fix().success is True + + +def test_find_interference(modeler: Modeler): + """Test to read geometry and find the interference problem areas.""" + design = modeler.open_file(FILES_DIR / "SimpleInterference.scdocx") + problem_areas = modeler.repair_tools.find_interferences(design.bodies, False) + assert len(problem_areas) == 1 + assert len(problem_areas[0].bodies) == 2 + + +def test_fix_interference(modeler: Modeler): + """Test to read geometry and fix ind the interference problem areas.""" + design = modeler.open_file(FILES_DIR / "SimpleInterference.scdocx") + problem_areas = modeler.repair_tools.find_interferences(design.bodies, False) + result = problem_areas[0].fix() + assert result.success is True + + +def test_find_and_fix_duplicate_faces(modeler: Modeler): + """Test to read geometry, find and fix duplicate faces and validate they are removed.""" + design = modeler.open_file(FILES_DIR / "DuplicateFaces.scdocx") + assert len(design.bodies) == 7 + areas = modeler.repair_tools.find_duplicate_faces(design.bodies) + assert len(areas) == 6 + for area in areas: + area.fix() + assert len(design.bodies) == 1 + + +def test_find_and_fix_extra_edges_problem_areas(modeler: Modeler): + """Test to read geometry, find and fix extra edges and validate they are removed.""" + design = modeler.open_file(FILES_DIR / "ExtraEdges_NoComponents.scdocx") + assert len(design.bodies) == 3 + starting_edge_count = 0 + for body in design.bodies: + starting_edge_count += len(body.edges) + assert starting_edge_count == 69 + extra_edges = modeler.repair_tools.find_extra_edges(design.bodies) + assert len(extra_edges) == 6 + for edge in extra_edges: + edge.fix() + final_edge_count = 0 + for body in design.bodies: + final_edge_count += len(body.edges) + assert final_edge_count == 36 + + +def test_find_and_fix_extra_edges_in_components(modeler: Modeler): + """ + Test to read geometry, find and fix extra edges in components and validate they are + removed. + """ + design = modeler.open_file(FILES_DIR / "ExtraEdges.scdocx") + len(design.components) + starting_edge_count = 0 + for components in design.components: + starting_edge_count += len(components.bodies[0].edges) + assert starting_edge_count == 69 + for components in design.components: + extra_edges = modeler.repair_tools.find_extra_edges(components.bodies) + for edge in extra_edges: + edge.fix() + final_edge_count = 0 + for components in design.components: + final_edge_count += len(components.bodies[0].edges) + assert final_edge_count == 36 + + +def test_find_and_fix_inexact_edges(modeler: Modeler): + """Test to read geometry, find and fix inexact edges and validate they are fixed removed.""" + design = modeler.open_file(FILES_DIR / "gear.scdocx") + assert len(design.bodies[0].edges) == 993 + inexact_edges = modeler.repair_tools.find_inexact_edges(design.bodies) + assert len(inexact_edges) == 272 + for i in inexact_edges: + i.fix() + assert len(design.bodies[0].edges) == 993 + inexact_edges = modeler.repair_tools.find_inexact_edges(design.bodies) + assert len(inexact_edges) == 0 + + +def test_find_and_fix_missing_faces(modeler: Modeler): + """Test to read geometry, find and fix missing faces and validate that we now have solids.""" + design = modeler.open_file(FILES_DIR / "MissingFaces.scdocx") + assert len(design.bodies) == 1 + assert design.bodies[0].is_surface + assert len(design.components) == 3 + for comp in design.components: + assert comp.bodies[0].is_surface + missing_faces = modeler.repair_tools.find_missing_faces(design.bodies) + for face in missing_faces: + face.fix() + for components in design.components: + missing_faces = modeler.repair_tools.find_missing_faces(components.bodies) + for face in missing_faces: + face.fix() + assert not design.bodies[0].is_surface + for comp in design.components: + assert not comp.bodies[0].is_surface + + +def test_find_and_fix_short_edges_problem_areas(modeler: Modeler): + """Test to read geometry, find and fix short edges and validate they are fixed removed.""" + design = modeler.open_file(FILES_DIR / "ShortEdges.scdocx") + assert len(design.bodies[0].edges) == 685 + short_edges = modeler.repair_tools.find_short_edges(design.bodies, 0.000127) + assert len(short_edges) == 8 + for i in short_edges: + i.fix() + assert len(design.bodies[0].edges) == 675 ##We get 673 edges if we repair all in one go + + +def test_find_and_fix_split_edges_problem_areas(modeler: Modeler): + """Test to read geometry, find and fix split edges and validate they are fixed removed.""" + design = modeler.open_file(FILES_DIR / "bracket-with-split-edges.scdocx") + assert len(design.bodies[0].edges) == 304 + split_edges = modeler.repair_tools.find_split_edges(design.bodies, 2.61799, 0.01) + assert len(split_edges) == 166 + for i in split_edges: + try: # Try/Except is a workaround. Having .alive would be better + i.fix() + except Exception: + pass + assert len(design.bodies[0].edges) == 169 + + +def test_find_and_stitch_and_missing_faces(modeler: Modeler): + """Test to read geometry,fix stitch faces and fix missing faces, verify that we get a solid.""" + design = modeler.open_file(FILES_DIR / "Stitch_And_MissingFaces.scdocx") + assert len(design.bodies) == 132 + stitch_faces = modeler.repair_tools.find_stitch_faces(design.bodies) + assert len(stitch_faces) == 1 + for i in stitch_faces: + i.fix() + assert len(design.bodies) == 1 + assert design.bodies[0].is_surface + missing_faces = modeler.repair_tools.find_missing_faces(design.bodies) + for face in missing_faces: + face.fix() + assert len(design.bodies) == 1 + assert not design.bodies[0].is_surface + + +def test_find_simplify(modeler: Modeler): + """Test to read geometry and find it's unsimplified face problem areas.""" + design = modeler.open_file(FILES_DIR / "SOBracket2.scdocx") + problem_areas = modeler.repair_tools.find_simplify(design.bodies) + assert len(problem_areas) == 46 + + +def test_fix_simplify(modeler: Modeler): + """Test to read geometry and find and fix it's unsimplified face problem areas.""" + design = modeler.open_file(FILES_DIR / "SOBracket2.scdocx") + problem_areas = modeler.repair_tools.find_simplify(design.bodies) + assert problem_areas[0].fix().success is True + + +def test_find_and_fix_short_edges(modeler: Modeler): + """Test to read geometry, find and fix short edges and validate they are fixed removed.""" + design = modeler.open_file(FILES_DIR / "ShortEdges.scdocx") + assert len(design.bodies[0].edges) == 685 + modeler.repair_tools.find_and_fix_short_edges(design.bodies, 0.000127) + assert len(design.bodies[0].edges) == 673 ##We get 673 edges if we repair all in one go + + +def test_find_and_fix_split_edges(modeler: Modeler): + """Test to read geometry, find and fix split edges and validate they are fixed removed.""" + design = modeler.open_file(FILES_DIR / "bracket-with-split-edges.scdocx") + assert len(design.bodies[0].edges) == 304 + modeler.repair_tools.find_and_fix_split_edges(design.bodies, 2.61799, 0.01) + assert len(design.bodies[0].edges) == 138 + + +def test_find_and_fix_extra_edges(modeler: Modeler): + """Test to read geometry, find and fix extra edges and validate they are removed.""" + design = modeler.open_file(FILES_DIR / "ExtraEdges_NoComponents.scdocx") + assert len(design.bodies) == 3 + starting_edge_count = 0 + for body in design.bodies: + starting_edge_count += len(body.edges) + assert starting_edge_count == 69 + modeler.repair_tools.find_and_fix_extra_edges(design.bodies) + final_edge_count = 0 + for body in design.bodies: + final_edge_count += len(body.edges) + assert final_edge_count == 36 diff --git a/tests/integration/test_runscript.py b/tests/integration/test_runscript.py index a70ac7f580..7f017ef060 100644 --- a/tests/integration/test_runscript.py +++ b/tests/integration/test_runscript.py @@ -30,13 +30,13 @@ from ansys.geometry.core.math.point import Point2D from ansys.geometry.core.sketch import Sketch -from .conftest import DSCOSCRIPTS_FILES_DIR, skip_if_linux +from .conftest import DSCOSCRIPTS_FILES_DIR, skip_if_core_service # Python (.py) def test_python_simple_script(modeler: Modeler): - # Skip on Linux - skip_if_linux(modeler, test_python_simple_script.__name__, "run_discovery_script_file") + # Skip on CoreService + skip_if_core_service(modeler, test_python_simple_script.__name__, "run_discovery_script_file") result = modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "simple_script.py") pattern_db = re.compile(r"SpaceClaim\.Api\.[A-Za-z0-9]+\.DesignBody", re.IGNORECASE) @@ -49,8 +49,8 @@ def test_python_simple_script(modeler: Modeler): def test_python_simple_script_ignore_api_version( modeler: Modeler, caplog: pytest.LogCaptureFixture ): - # Skip on Linux - skip_if_linux( + # Skip on CoreService + skip_if_core_service( modeler, test_python_simple_script_ignore_api_version.__name__, "run_discovery_script_file" ) @@ -73,15 +73,17 @@ def test_python_simple_script_ignore_api_version( def test_python_failing_script(modeler: Modeler): - # Skip on Linux - skip_if_linux(modeler, test_python_failing_script.__name__, "run_discovery_script_file") + # Skip on CoreService + skip_if_core_service(modeler, test_python_failing_script.__name__, "run_discovery_script_file") with pytest.raises(GeometryRuntimeError): modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "failing_script.py") def test_python_integrated_script(modeler: Modeler): - # Skip on Linux - skip_if_linux(modeler, test_python_integrated_script.__name__, "run_discovery_script_file") + # Skip on CoreService + skip_if_core_service( + modeler, test_python_integrated_script.__name__, "run_discovery_script_file" + ) # Tests the workflow of creating a design in PyAnsys Geometry, modifying it with a script, # and continuing to use it in PyAnsys Geometry @@ -103,8 +105,8 @@ def test_python_integrated_script(modeler: Modeler): # SpaceClaim (.scscript) def test_scscript_simple_script(modeler: Modeler): - # Skip on Linux - skip_if_linux(modeler, test_scscript_simple_script.__name__, "run_discovery_script_file") + # Skip on CoreService + skip_if_core_service(modeler, test_scscript_simple_script.__name__, "run_discovery_script_file") result = modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "simple_script.scscript") assert len(result) == 2 @@ -117,8 +119,8 @@ def test_scscript_simple_script(modeler: Modeler): # Discovery (.dscript) def test_dscript_simple_script(modeler: Modeler): - # Skip on Linux - skip_if_linux(modeler, test_dscript_simple_script.__name__, "run_discovery_script_file") + # Skip on CoreService + skip_if_core_service(modeler, test_dscript_simple_script.__name__, "run_discovery_script_file") result = modeler.run_discovery_script_file(DSCOSCRIPTS_FILES_DIR / "simple_script.dscript") assert len(result) == 2 diff --git a/tests/integration/test_tessellation.py b/tests/integration/test_tessellation.py index 2e9f12a563..973190670e 100644 --- a/tests/integration/test_tessellation.py +++ b/tests/integration/test_tessellation.py @@ -69,7 +69,7 @@ def test_body_tessellate(modeler: Modeler): blocks_2 = body_2.tessellate() assert "MultiBlock" in str(blocks_2) assert blocks_2.n_blocks == 3 - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): assert blocks_2.bounds == pytest.approx( [0.019999999999999997, 0.04, 0.020151922469877917, 0.03984807753012208, 0.0, 0.03], rel=1e-6, @@ -86,7 +86,7 @@ def test_body_tessellate(modeler: Modeler): # Tessellate the body merging the individual faces mesh_2 = body_2.tessellate(merge=True) - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): assert "PolyData" in str(mesh_2) assert mesh_2.n_cells == 72 assert mesh_2.n_points == 76 @@ -135,7 +135,7 @@ def test_component_tessellate(modeler: Modeler): mesh = comp.tessellate() comp.plot() assert "PolyData" in str(mesh) - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): assert mesh.n_cells == 3280 assert mesh.n_faces == 3280 assert mesh.n_arrays == 0 diff --git a/tests/integration/test_trimmed_geometry.py b/tests/integration/test_trimmed_geometry.py index 30715083b3..7a3ff0920a 100644 --- a/tests/integration/test_trimmed_geometry.py +++ b/tests/integration/test_trimmed_geometry.py @@ -87,7 +87,7 @@ def create_hedgehog(modeler: Modeler): create_sketch_line(design, p1, p2) current_gap += 1 # Add isoparametric curves, not on linux - if modeler.client.backend_type != BackendType.LINUX_SERVICE: + if not BackendType.is_core_service(modeler.client.backend_type): param = 0.20 while param <= 1: for face in body.faces: diff --git a/tests/test_math.py b/tests/test_math.py index 12586625dd..3469866829 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -752,6 +752,81 @@ def test_matrix_44(): assert "Matrix44 should only be a 2D array of shape (4,4)." in str(val.value) +def test_create_translation_matrix(): + """Test the creation of a translation matrix.""" + + vector = Vector3D([1, 2, 3]) + expected_matrix = Matrix44([[1, 0, 0, 1], [0, 1, 0, 2], [0, 0, 1, 3], [0, 0, 0, 1]]) + translation_matrix = Matrix44.create_translation(vector) + assert np.array_equal(expected_matrix, translation_matrix) + assert translation_matrix.is_translation() + + +def test_is_translation(): + """Test the is_translation method of the Matrix44 class.""" + matrix = Matrix44([[1, 0, 0, 5], [0, 1, 0, 3], [0, 0, 1, 2], [0, 0, 0, 1]]) + assert matrix.is_translation() + # Test the identity matrix + identity_matrix = Matrix44([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + assert identity_matrix.is_translation() is False + # Test a matrix that is not a translation (rotation matrix) + rotation_matrix = Matrix44([[0, -1, 0, 0], [1, 0, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + assert rotation_matrix.is_translation() is False + rotation_matrix = Matrix44([[1, 0, 1, 5], [0, 1, 0, 3], [0, 0, 1, 2], [0, 0, 0, 1]]) + assert (rotation_matrix.is_translation()) is False + + +def test_create_rotation_matrix(): + """Test the creation of a rotation matrix.""" + # Test 0: No rotation + direction_x = Vector3D([1, 0, 0]) + direction_y = Vector3D([0, 1, 0]) + expected_matrix = Matrix44([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + rotation_matrix = Matrix44.create_rotation(direction_x, direction_y) + assert np.array_equal(expected_matrix, rotation_matrix) + + # Test: Rotation around Z-axis by 90 degrees counter clockwise + + new_x = Vector3D([0, 1, 0]) + new_y = Vector3D([-1, 0, 0]) + + rotation_matrix = Matrix44.create_rotation(new_x, new_y) + p_before = np.array([1, 0, 0, 0]) + p_after = rotation_matrix * p_before + p_expected = np.array([0, 1, 0, 0]) + assert np.array_equal(p_after, p_expected), "Rotation around Z-axis failed" + + # Test: Rotation around Z-axis by 180 degrees counter clockwise + new_x = Vector3D([-1, 0, 0]) + new_y = Vector3D([0, -1, 0]) + + rotation_matrix = Matrix44.create_rotation(new_x, new_y) + p_before = np.array([1, 0, 0, 0]) + p_after = rotation_matrix * p_before + p_expected = np.array([-1, 0, 0, 0]) + assert np.array_equal(p_after, p_expected), "Rotation around Z-axis failed" + + # Test: Rotation around Z-axis by 270 degrees counter clockwise + new_x = Vector3D([0, -1, 0]) + new_y = Vector3D([1, 0, 0]) + new_z = Vector3D([0, 0, 1]) + + rotation_matrix = Matrix44.create_rotation(new_x, new_y, new_z) + p_before = np.array([3, 4, 0, 0]) + p_after = rotation_matrix * p_before + p_expected = np.array([4, -3, 0, 0]) + assert np.array_equal(p_after, p_expected), "Rotation around Z-axis failed" + + # Rotation around Z for one radian + direction_x = Vector3D([1, 0, 0]) + direction_y = Vector3D([0, 1, 0]) + direction_z = Vector3D([0, 0, 1]) + + expected_matrix = Matrix44([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]) + rotation_matrix = Matrix44.create_rotation(direction_x, direction_y, direction_z) + assert np.array_equal(expected_matrix, rotation_matrix) + + def test_frame(): """``Frame`` construction and equivalency.""" origin = Point3D([42, 99, 13]) diff --git a/tests/test_primitives.py b/tests/test_primitives.py index 72156290d5..841ea34c10 100644 --- a/tests/test_primitives.py +++ b/tests/test_primitives.py @@ -35,7 +35,17 @@ Vector3D, ) from ansys.geometry.core.misc import DEFAULT_UNITS, UNITS, Accuracy, Distance -from ansys.geometry.core.shapes import Circle, Cone, Cylinder, Ellipse, Line, ParamUV, Sphere, Torus +from ansys.geometry.core.shapes import ( + Circle, + Cone, + Cylinder, + Ellipse, + Line, + NURBSCurve, + ParamUV, + Sphere, + Torus, +) def test_cylinder(): @@ -917,3 +927,103 @@ def test_ellipse_evaluation(): ) assert Accuracy.length_is_equal(eval2.curvature, 0.31540327) + + +def test_nurbs_curve_from_control_points(): + """Test ``NURBSCurve`` construction from control points.""" + control_points = [ + Point3D([0, 0, 0]), + Point3D([1, 1, 0]), + Point3D([2, 0, 0]), + ] + degree = 2 + knots = [0, 0, 0, 1, 1, 1] + nurbs_curve = NURBSCurve.from_control_points( + control_points=control_points, degree=degree, knots=knots + ) + assert nurbs_curve.degree == 2 + assert nurbs_curve.knots == [0, 0, 0, 1, 1, 1] + assert nurbs_curve.control_points == control_points + assert nurbs_curve.weights == [1, 1, 1] + + # Test with a different weight vector + weights = [1, 2, 1] + nurbs_curve_weights = NURBSCurve.from_control_points( + control_points=control_points, degree=degree, knots=knots, weights=weights + ) + + assert nurbs_curve_weights.degree == 2 + assert nurbs_curve_weights.knots == [0, 0, 0, 1, 1, 1] + assert nurbs_curve_weights.control_points == control_points + assert nurbs_curve_weights.weights == weights + + # Verify that the curves are different + assert nurbs_curve != nurbs_curve_weights + + +def test_nurbs_curve_evaluation(): + """Test ``NURBSCurve`` evaluation.""" + control_points = [ + Point3D([0, 0, 0]), + Point3D([1, 1, 0]), + Point3D([2, 0, 0]), + ] + degree = 2 + knots = [0, 0, 0, 1, 1, 1] + nurbs_curve = NURBSCurve.from_control_points( + control_points=control_points, degree=degree, knots=knots + ) + + # Test evaluation at 0 + eval = nurbs_curve.evaluate(0) + assert eval is not None + assert eval.is_set() is True + assert eval.parameter == 0 + assert eval.position == Point3D([0, 0, 0]) + assert eval.first_derivative == Vector3D([2, 2, 0]) + assert eval.second_derivative == Vector3D([0, -4, 0]) + assert np.isclose(eval.curvature, 0.3535533905932737) + + # Test evaluation at 0.5 + eval = nurbs_curve.evaluate(0.5) + assert eval is not None + assert eval.is_set() is True + assert eval.parameter == 0.5 + assert eval.position == Point3D([1, 0.5, 0]) + assert eval.first_derivative == Vector3D([2, 0, 0]) + assert eval.second_derivative == Vector3D([0, -4, 0]) + assert np.isclose(eval.curvature, 1) + + # Test evaluation at 1 + eval = nurbs_curve.evaluate(1) + assert eval is not None + assert eval.is_set() is True + assert eval.parameter == 1 + assert eval.position == Point3D([2, 0, 0]) + assert eval.first_derivative == Vector3D([2, -2, 0]) + assert eval.second_derivative == Vector3D([0, -4, 0]) + assert np.isclose(eval.curvature, 0.3535533905932737) + + +def test_nurbs_curve_point_projection(): + """Test projection of a point onto a NURBS curve.""" + # Define the NUTBS curve + control_points = [ + Point3D([0, 0, 0]), + Point3D([1, 1, 0]), + Point3D([2, 0, 0]), + ] + degree = 2 + knots = [0, 0, 0, 1, 1, 1] + nurbs_curve = NURBSCurve.from_control_points( + control_points=control_points, degree=degree, knots=knots + ) + + # Test projection of a point on the curve + point = Point3D([1, 3, 0]) + projection = nurbs_curve.project_point(point, initial_guess=0.1) + + assert projection is not None + assert projection.is_set() is True + assert np.allclose(projection.position, Point3D([1, 0.5, 0])) + assert np.isclose(projection.parameter, 0.5)