diff --git a/.github/scripts/publish-bonsai-releases.py b/.github/scripts/publish-bonsai-releases.py new file mode 100755 index 0000000000..a393104e7c --- /dev/null +++ b/.github/scripts/publish-bonsai-releases.py @@ -0,0 +1,95 @@ +#!/usr/bin/env -S uv run +# /// script +# dependencies = [ +# "PyGithub", +# "requests", +# ] +# /// + +import os +from pathlib import Path + +import requests +from github import Github +from github.GitReleaseAsset import GitReleaseAsset + +EXTENSION_ID = "bonsai" +CURRENT_PYTHON_VERSION = "py313" +CURRENT_PLATFORMS = ["linux-x64", "macos-arm64", "windows-x64"] + + +def publish_asset(asset: GitReleaseAsset, token: str, repo_root: Path) -> None: + """ + Publish an asset to Blender Extensions. + Reference: https://extensions.blender.org/api/v1/swagger + """ + temp_path = repo_root / asset.name + + response = requests.get(asset.browser_download_url) + response.raise_for_status() + temp_path.write_bytes(response.content) + + url = f"https://extensions.blender.org/api/v1/extensions/{EXTENSION_ID}/versions/upload/" + headers = {"Authorization": f"Bearer {token}"} + + files = {"version_file": temp_path.read_bytes()} + response = requests.post(url, headers=headers, files=files) + response.raise_for_status() + + temp_path.unlink() + + print(f"āœ“ Published {asset.name}") + + +def main() -> None: + token = os.getenv("BLENDER_EXTENSIONS_TOKEN") + if not token: + raise Exception("BLENDER_EXTENSIONS_TOKEN environment variable not set") + + # Get the repository root + repo_root = Path(__file__).parent.parent.parent + + # Read VERSION file + version_file = repo_root / "VERSION" + version = version_file.read_text().strip() + + print(f"Current VERSION: {version}") + + tag_name = f"bonsai-{version}" + + # Get release from GitHub + gh = Github() + gh_repo = gh.get_repo("IfcOpenShell/IfcOpenShell") + release = gh_repo.get_release(tag_name) + + assets = release.get_assets() + + asset_platform_map: dict[str, tuple[GitReleaseAsset, str]] = {} + for asset in assets: + if CURRENT_PYTHON_VERSION not in asset.name: + continue + for platform in CURRENT_PLATFORMS: + if platform in asset.name: + asset_platform_map[asset.name] = (asset, platform) + break + + if len(asset_platform_map) != len(CURRENT_PLATFORMS): + found_platforms = {platform for _, (_, platform) in asset_platform_map.items()} + missing_platforms = set(CURRENT_PLATFORMS) - found_platforms + raise Exception( + f"Expected {len(CURRENT_PLATFORMS)} assets but found {len(asset_platform_map)}. " + f"Missing: {', '.join(sorted(missing_platforms))}" + ) + + print("\nRelease assets:") + for asset_name in sorted(asset_platform_map.keys()): + print(f"- {asset_name}") + + # https://extensions.blender.org/api/v1/swagger + print("\nPublishing assets to Blender Extensions:") + for asset_name, (asset, platform) in asset_platform_map.items(): + publish_asset(asset, token, repo_root) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build_osx.yml b/.github/workflows/build_osx.yml index 0f6bb702e9..c758470a79 100644 --- a/.github/workflows/build_osx.yml +++ b/.github/workflows/build_osx.yml @@ -53,7 +53,7 @@ jobs: python ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: mac-${{ matrix.arch }} diff --git a/.github/workflows/build_pyodide.yml b/.github/workflows/build_pyodide.yml index 7da3bb408c..050b542cff 100644 --- a/.github/workflows/build_pyodide.yml +++ b/.github/workflows/build_pyodide.yml @@ -29,7 +29,7 @@ jobs: python ../IfcOpenShell/nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ubuntu-22.04-${{ runner.arch }} diff --git a/.github/workflows/build_rocky.yml b/.github/workflows/build_rocky.yml index 710b918564..0de26089f6 100644 --- a/.github/workflows/build_rocky.yml +++ b/.github/workflows/build_rocky.yml @@ -9,6 +9,13 @@ jobs: container: rockylinux:9 steps: + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install Python + # Installs latest Python version so it's preferred by uv over Rocky's system Python. + run: uv python install + - name: Install Dependencies run: | dnf update -y @@ -17,7 +24,6 @@ jobs: sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \ readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \ findutils xz byacc - python3 -m pip install typing_extensions git config --global --add safe.directory '*' - name: Install aws cli @@ -45,10 +51,10 @@ jobs: - name: Unpack Dependencies run: | cd build - python3 ../nix/cache_dependencies.py unpack + uv run ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ubuntu-22.04-${{ runner.arch }}-rockylinux9 @@ -56,7 +62,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() @@ -71,7 +77,7 @@ jobs: - name: Pack Dependencies run: | cd build - python3 ../nix/cache_dependencies.py pack + uv run ../nix/cache_dependencies.py pack - name: Commit and Push Changes to Build Repository run: | diff --git a/.github/workflows/build_rocky_arm.yml b/.github/workflows/build_rocky_arm.yml index b54e62ccef..e7401f1f63 100644 --- a/.github/workflows/build_rocky_arm.yml +++ b/.github/workflows/build_rocky_arm.yml @@ -9,6 +9,13 @@ jobs: container: arm64v8/rockylinux:9 steps: + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + + - name: Install Python + # Installs latest Python version so it's preferred by uv over Rocky's system Python. + run: uv python install + - name: Install Dependencies run: | dnf update -y @@ -17,7 +24,6 @@ jobs: sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \ readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \ findutils xz byacc - python3 -m pip install typing_extensions git config --global --add safe.directory '*' - name: Install aws cli @@ -45,10 +51,10 @@ jobs: - name: Unpack Dependencies run: | cd build - python3 ../nix/cache_dependencies.py unpack + uv run ../nix/cache_dependencies.py unpack - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ubuntu-22.04-${{ runner.arch }}-rockylinux9 @@ -56,7 +62,7 @@ jobs: shell: bash run: | set -o pipefail - CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log + CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log - name: Upload Build Logs if: always() @@ -71,7 +77,7 @@ jobs: - name: Pack Dependencies run: | cd build - python3 ../nix/cache_dependencies.py pack + uv run ../nix/cache_dependencies.py pack - name: Commit and Push Changes to Build Repository run: | diff --git a/.github/workflows/build_win.yml b/.github/workflows/build_win.yml index c2ae75143d..5b976f7886 100644 --- a/.github/workflows/build_win.yml +++ b/.github/workflows/build_win.yml @@ -52,7 +52,7 @@ jobs: } - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: win-${{ matrix.arch }} # Windows ccache needs ~1GB diff --git a/.github/workflows/ci-bonsai-daily.yml b/.github/workflows/ci-bonsai-daily.yml index ddfa64a47a..5e1e90e8d9 100644 --- a/.github/workflows/ci-bonsai-daily.yml +++ b/.github/workflows/ci-bonsai-daily.yml @@ -109,7 +109,7 @@ jobs: # Ensure Bonsai and ifcsverchok enable/disable works before uploading to extensions repo. # Download Blender. - wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-linux-x64.tar.xz + wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.1/blender-5.1.0-linux-x64.tar.xz tar -xf blender.tar.xz # Setup Blender. @@ -122,7 +122,7 @@ jobs: pip install -r requirements.txt python setup_extensions_repo.py --last-tag cd .. - bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)" + bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py313*-linux-x64.zip)" # Install Bonsai. blender --command extension install-file -r user_default -e $bonsai_zip diff --git a/.github/workflows/ci-bonsai.yml b/.github/workflows/ci-bonsai.yml index 4c8364a958..6fcc61658f 100644 --- a/.github/workflows/ci-bonsai.yml +++ b/.github/workflows/ci-bonsai.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py311, py312] + pyver: [py311, py312, py313] config: - { name: "Windows Build", @@ -42,6 +42,11 @@ jobs: name: "MacOS ARM Build", short_name: macosm1, } + exclude: + # Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0. + - pyver: py313 + config: + short_name: macos steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 # https://github.com/actions/setup-python diff --git a/.github/workflows/ci-ifcedit-pypi.yaml b/.github/workflows/ci-ifcedit-pypi.yaml new file mode 100644 index 0000000000..d961c72f56 --- /dev/null +++ b/.github/workflows/ci-ifcedit-pypi.yaml @@ -0,0 +1,35 @@ +name: ci-ifcedit-pypi + +on: + workflow_dispatch: + +jobs: + activate: + runs-on: ubuntu-latest + if: | + github.repository == 'IfcOpenShell/IfcOpenShell' + steps: + - name: Set env + run: echo ok go + + build: + needs: activate + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Compile + run: | + pip install build + cd src/ifcedit && + make dist IS_STABLE=TRUE + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: src/ifcedit/dist diff --git a/.github/workflows/ci-ifcmcp-pypi.yaml b/.github/workflows/ci-ifcmcp-pypi.yaml new file mode 100644 index 0000000000..388c10217a --- /dev/null +++ b/.github/workflows/ci-ifcmcp-pypi.yaml @@ -0,0 +1,36 @@ +name: ci-ifcmcp-pypi + +on: + workflow_dispatch: + +jobs: + activate: + runs-on: ubuntu-latest + if: | + github.repository == 'IfcOpenShell/IfcOpenShell' + steps: + - name: Set env + run: echo ok go + + build: + needs: activate + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Compile + run: | + pip install build + cd src/ifcmcp && + make dist IS_STABLE=TRUE + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: src/ifcmcp/dist + verbose: true diff --git a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml index 5c1b80bcc6..c34cf50e0f 100644 --- a/.github/workflows/ci-ifcopenshell-conda-cleaner.yml +++ b/.github/workflows/ci-ifcopenshell-conda-cleaner.yml @@ -24,7 +24,7 @@ jobs: if: | github.repository == 'IfcOpenShell/IfcOpenShell' steps: - - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba + - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba with: environment-name: test-env create-args: >- diff --git a/.github/workflows/ci-ifcopenshell-conda-daily.yml b/.github/workflows/ci-ifcopenshell-conda-daily.yml index b648538ed7..3d18316963 100644 --- a/.github/workflows/ci-ifcopenshell-conda-daily.yml +++ b/.github/workflows/ci-ifcopenshell-conda-daily.yml @@ -84,7 +84,7 @@ jobs: run: | curl -L https://github.com/phracker/MacOSX-SDKs/releases/download/11.3/MacOSX10.13.sdk.tar.xz | tar -xvJf - -C /Users/runner/work/ - - uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba + - uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba with: environment-name: test-env create-args: >- diff --git a/.github/workflows/ci-ifcopenshell-docker.yml b/.github/workflows/ci-ifcopenshell-docker.yml index 2eee5eff1a..50978c9d6f 100644 --- a/.github/workflows/ci-ifcopenshell-docker.yml +++ b/.github/workflows/ci-ifcopenshell-docker.yml @@ -35,7 +35,7 @@ jobs: - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 - name: Build ifcopenshell diff --git a/.github/workflows/ci-ifcopenshell-python-pypi.yml b/.github/workflows/ci-ifcopenshell-python-pypi.yml index 526aa09b64..7988a0bc6a 100644 --- a/.github/workflows/ci-ifcopenshell-python-pypi.yml +++ b/.github/workflows/ci-ifcopenshell-python-pypi.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py39, py310, py311, py312, py313, py314] + pyver: [py310, py311, py312, py313, py314] config: - { name: "Windows 64bit", diff --git a/.github/workflows/ci-ifcopenshell-python.yml b/.github/workflows/ci-ifcopenshell-python.yml index 5d34335ada..fe99f4857b 100644 --- a/.github/workflows/ci-ifcopenshell-python.yml +++ b/.github/workflows/ci-ifcopenshell-python.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - pyver: [py39, py310, py311, py312, py313, py314] + pyver: [py310, py311, py312, py313, py314] config: - { name: "Windows 64bit", diff --git a/.github/workflows/ci-ifcquery-pypi.yaml b/.github/workflows/ci-ifcquery-pypi.yaml new file mode 100644 index 0000000000..9dc39a9304 --- /dev/null +++ b/.github/workflows/ci-ifcquery-pypi.yaml @@ -0,0 +1,35 @@ +name: ci-ifcquery-pypi + +on: + workflow_dispatch: + +jobs: + activate: + runs-on: ubuntu-latest + if: | + github.repository == 'IfcOpenShell/IfcOpenShell' + steps: + - name: Set env + run: echo ok go + + build: + needs: activate + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Compile + run: | + pip install build + cd src/ifcquery && + make dist IS_STABLE=TRUE + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + packages_dir: src/ifcquery/dist diff --git a/.github/workflows/ci-black-formatting.yaml b/.github/workflows/ci-lint.yaml similarity index 79% rename from .github/workflows/ci-black-formatting.yaml rename to .github/workflows/ci-lint.yaml index bbe9b37c26..4ef84f8cbb 100644 --- a/.github/workflows/ci-black-formatting.yaml +++ b/.github/workflows/ci-lint.yaml @@ -1,4 +1,4 @@ -name: ci-black-formatting +name: ci-lint on: push: @@ -7,6 +7,9 @@ on: jobs: lint-formatting: runs-on: ubuntu-latest + env: + MIN_IOS_PY_VERSION: "3.10" + MIN_BLENDER_PY_VERSION: "3.11" steps: - name: Action - checkout repository uses: actions/checkout@v6 @@ -14,12 +17,12 @@ jobs: - name: Action - install python uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: ${{ env.MIN_IOS_PY_VERSION }} - name: Action - install python uses: actions/setup-python@v6 with: - python-version: "3.11" + python-version: ${{ env.MIN_BLENDER_PY_VERSION }} - name: Install dependencies run: | @@ -27,6 +30,7 @@ jobs: uv tool install ruff uv tool install black uv tool install poethepoet + uv tool install ty # black doesn't catch all syntax errors, so we check them explicitly. - name: Check syntax errors @@ -35,8 +39,8 @@ jobs: ERROR=0 # Using 2 Python versions - one minimum required for IfcOpenShell # and other that's used by Blender currently. - python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1 - python3.11 -W error -m compileall -q src/bonsai || ERROR=1 + python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1 + python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1 exit $ERROR continue-on-error: true @@ -54,6 +58,13 @@ jobs: black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py continue-on-error: true + - name: ty check + id: ty + run: | + poe ty-venv + poe ty + continue-on-error: true + - name: Ruff check id: ruff run: | @@ -84,8 +95,7 @@ jobs: echo "\`\`\`" >> $GITHUB_STEP_SUMMARY } - run_check poe ruff-main - run_check poe ruff-old + run_check poe ruff exit $ERROR continue-on-error: true @@ -102,4 +112,7 @@ jobs: if [ "${{ steps.ruff.outcome }}" != "success" ]; then echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1 fi + if [ "${{ steps.ty.outcome }}" != "success" ]; then + echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1 + fi exit $ERROR diff --git a/.github/workflows/ci-pyodide-wasm-release.yml b/.github/workflows/ci-pyodide-wasm-release.yml new file mode 100644 index 0000000000..0e3017f819 --- /dev/null +++ b/.github/workflows/ci-pyodide-wasm-release.yml @@ -0,0 +1,46 @@ +name: Release Pyodide WASM Wheel + +on: + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout IfcOpenShell + uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Build wheel + working-directory: pyodide + run: uv run pack_wheel.py --build + + - name: Find wheel + id: wheel + run: | + WHEEL=$(ls pyodide/dist/ifcopenshell-*.whl) + echo "path=$WHEEL" >> $GITHUB_OUTPUT + echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT + + - name: Checkout wasm-wheels + uses: actions/checkout@v6 + with: + repository: IfcOpenShell/wasm-wheels + path: wasm-wheels + token: ${{ secrets.BUILD_REPO_TOKEN }} + + - name: Commit and push wheel to wasm-wheels + run: | + WHEEL_NAME="${{ steps.wheel.outputs.name }}" + cp "${{ steps.wheel.outputs.path }}" "wasm-wheels/$WHEEL_NAME" + cd wasm-wheels + git config user.name "IfcOpenBot" + git config user.email "ifcopenbot@ifcopenshell.org" + git add "$WHEEL_NAME" + git commit -m "Add $WHEEL_NAME" + VERSION=$(cat ../VERSION) + git tag "v${VERSION}" + git push origin main + git push origin "v${VERSION}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 488b1c2bcd..ea7fb590e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,7 +79,7 @@ jobs: libhdf5-dev libcgal-dev libeigen3-dev - name: ccache - uses: hendrikmuhs/ccache-action@v1.2.20 + uses: hendrikmuhs/ccache-action@v1.2.23 with: key: ubuntu-22.04-${{ runner.arch }} @@ -254,6 +254,7 @@ jobs: cd ../ifcpatch && make test || ERROR=1 pip install -e ../ifctester --no-deps cd ../ifctester && make test || ERROR=1 + make build-ids-docs || ERROR=1 # Run mathutils related tests at the end to ensure no other code is relying on mathutils. cd ../ifcopenshell-python pip install mathutils diff --git a/.github/workflows/docs-deployment.yml b/.github/workflows/docs-deployment.yml deleted file mode 100644 index 3ff50b575e..0000000000 --- a/.github/workflows/docs-deployment.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build and Deploy Stable Documentation - -on: - workflow_dispatch: # Manual trigger - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - cd src/bonsai/docs - pip install -r requirements.txt # Run pip install from the docs directory - - - name: Build documentation - run: | - cd src/bonsai/docs - make html - - - name: Deploy to GitHub Pages (Stable) - uses: peaceiris/actions-gh-pages@v4 - with: - deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} - external_repository: IfcOpenShell/bonsaibim_org_docs - publish_branch: main - cname: docs.bonsaibim.org - publish_dir: src/bonsai/docs/_build/html \ No newline at end of file diff --git a/.github/workflows/publish-aichat-app.yaml b/.github/workflows/publish-aichat-app.yaml new file mode 100644 index 0000000000..20369577f5 --- /dev/null +++ b/.github/workflows/publish-aichat-app.yaml @@ -0,0 +1,65 @@ +name: Deploy AI chat App to static page repo + +permissions: + id-token: write + pages: write + +on: + push: + paths: + - 'src/ifcchat/**' + - '.github/workflows/publish-aichat-app.yaml' + branches: + - v0.8.0 + workflow_dispatch: + +jobs: + activate: + runs-on: ubuntu-latest + if: | + github.repository == 'IfcOpenShell/IfcOpenShell' + steps: + - name: Set env + run: echo ok go + + build: + needs: activate + runs-on: ubuntu-latest + steps: + - name: Checkout (recursive) + uses: actions/checkout@v6 + with: + submodules: recursive + fetch-depth: 0 + - name: Checkout intermediate Pages repo + uses: actions/checkout@v6 + with: + repository: IfcOpenShell/aichat_ifcopenshell_org_static_html + ref: gh-pages + path: output + token: ${{ secrets.WEBSITE_PUBLISH }} + - name: Sync demo app into target subfolder + run: | + rsync -av --delete --exclude='.git/' src/ifcchat/ output/ + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + - name: Download wheels + working-directory: output/ + run: | + pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist + - name: Commit and push if changed + working-directory: output + run: | + git config --global user.name 'IfcOpenBot' + git config --global user.email 'IfcOpenBot@users.noreply.github.com' + + git add . + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "$(git log --oneline -1)" + git push origin gh-pages diff --git a/.github/workflows/publish-bonsai-releases.yml b/.github/workflows/publish-bonsai-releases.yml new file mode 100644 index 0000000000..6d423deadf --- /dev/null +++ b/.github/workflows/publish-bonsai-releases.yml @@ -0,0 +1,16 @@ +name: Publish Bonsai Releases + +on: + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + + - run: uv run .github/scripts/publish-bonsai-releases.py + env: + BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }} diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml index 4aebe4266e..6b0141fc29 100644 --- a/.github/workflows/publish-pyodide-demo-app.yml +++ b/.github/workflows/publish-pyodide-demo-app.yml @@ -1,4 +1,4 @@ -name: Deploy Pyodide Demo App to GitHub Pages +name: Deploy Pyodide Demo App to static page repo permissions: id-token: write @@ -11,6 +11,7 @@ on: - '.github/workflows/publish-pyodide-demo-app.yml' branches: - v0.8.0 + workflow_dispatch: jobs: activate: @@ -30,21 +31,27 @@ jobs: with: submodules: recursive fetch-depth: 0 - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload static files as artifact - id: deployment - uses: actions/upload-pages-artifact@v4 + - name: Checkout intermediate Pages repo + uses: actions/checkout@v6 with: - path: src/pyodide/demo-app/ + repository: IfcOpenShell/wasm_ifcopenshell_org_static_html + ref: gh-pages + path: output + token: ${{ secrets.WEBSITE_PUBLISH }} + - name: Sync demo app into target subfolder + run: | + rsync -av --delete --exclude='.git/' src/pyodide/demo-app/ output/ + - name: Commit and push if changed + working-directory: output + run: | + git config --global user.name 'IfcOpenBot' + git config --global user.email 'IfcOpenBot@users.noreply.github.com' - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + git add . + if git diff --cached --quiet; then + echo "No changes to commit" + exit 0 + fi + + git commit -m "$(git log --oneline -1)" + git push origin gh-pages diff --git a/.gitignore b/.gitignore index a92e504001..80fbb01a00 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ /_installed-vs*-x*/ /build/ /src/examples/build/ +# ifctester docs output +/src/ifctester/test/build/ # output directories /cmake/out/ @@ -12,6 +14,7 @@ /src/ifcmax/out/ /src/ifcwrap/out/ /src/qtviewer/out/ +/src/ifctester/webapp/public/pyodide/ /win/BuildDepsCache*.txt @@ -80,10 +83,14 @@ src/ifcopenshell-python/test/build # bonsai i18n src/bonsai/bonsai/translations.py -# bonsai test temp files +# bonsai external dependencies (cloned for just ty checks) +src/bonsai/external_dependencies/ + +# bonsai test temp/cache files src/bonsai/test/files/temp -src/bonsai/test/files/basic.ifc.cache.blend -src/bonsai/test/files/basic.ifc.cache.sqlite +src/bonsai/test/files/*.cache.blend +src/bonsai/test/files/*.cache.json +src/bonsai/test/files/*.cache.sqlite # bonsai data src/bonsai/bonsai/bim/data/build/ diff --git a/README.md b/README.md index 6b77a04833..a44a6ff78c 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,14 @@ Contents | [ifcconvert](https://docs.ifcopenshell.org/ifcconvert.html) | CLI app to convert IFC to many other formats | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcconvert/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcconvert-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcconvert&expanded=true) | [ifccsv](https://docs.ifcopenshell.org/ifccsv.html) | Library and CLI app to export and import schedules from IFC | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifccsv?label=PyPI&color=006dad)](https://pypi.org/project/ifccsv/) | | [ifcdiff](https://docs.ifcopenshell.org/ifcdiff.html) | Compare changes between IFC models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcdiff?label=PyPI&color=006dad)](https://pypi.org/project/ifcdiff/) | +| [ifcedit](https://docs.ifcopenshell.org/ifcedit.html) | CLI wrapper for ifcopenshell.api IFC model mutation functions | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcedit?label=PyPI&color=006dad)](https://pypi.org/project/ifcedit/) | | [ifcfm](https://docs.ifcopenshell.org/ifcfm.html) | Extract IFC data for FM handover requirements | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcfm?label=PyPI&color=006dad)](https://pypi.org/project/ifcfm/) | | [ifcmax](https://docs.ifcopenshell.org/ifcmax.html) | Historic extension for IFC support in 3DS Max | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcmax.html) -| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) | +| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcopenshell-mcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell-mcp/) | +| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) | | [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](https://pypi.org/project/ifcpatch/) | -| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub Unstable](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*.*&label=GitHub-Unstable&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true) +| [ifcquery](https://docs.ifcopenshell.org/ifcquery.html) | CLI tool for querying and inspecting IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcquery?label=PyPI&color=006dad)](https://pypi.org/project/ifcquery/) | +| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true) | [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifctester?label=PyPI&color=006dad)](https://pypi.org/project/ifctester/) | The IfcOpenShell C++ codebase is split into multiple interal libraries: diff --git a/VERSION b/VERSION index 7ada0d303f..7fc2521fd7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.5 +0.8.6 diff --git a/choco/bonsai/choco_release.py b/choco/bonsai/choco_release.py index 681e6c58e5..ea53798c5d 100644 --- a/choco/bonsai/choco_release.py +++ b/choco/bonsai/choco_release.py @@ -13,6 +13,7 @@ import hashlib import os import pathlib import re +import subprocess from typing import NoReturn from urllib import request @@ -20,7 +21,7 @@ from github import Github def get_repo_tag_names() -> list[str]: - git_return = os.popen("git tag -l").read() + git_return = subprocess.check_output("git tag -l", text=True) tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name] print(f"{len(tag_names)} tag_names found in repo") return tag_names @@ -78,6 +79,10 @@ def get_release_zip(tag: str) -> tuple[str, str]: raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.") +def run(command: str) -> None: + subprocess.check_output(command) + + start = datetime.datetime.now() URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender" @@ -97,7 +102,7 @@ should_release = False target_release_tag = "" TARGET_OS = "windows-x64" -git_status = os.popen("git status").read() +git_status = subprocess.check_output("git status", text=True) print(git_status) for tag_name in get_repo_tag_names(): @@ -147,7 +152,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "") # url_blenderbim_py3x_win_zip release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag) -os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read() +subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose") # sha256sum_blenderbim_py310_win_zip sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name) @@ -201,13 +206,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful") print("\n_____ build choco.exe with mono") choco_version = "1.1.0" -os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read() -os.popen(f"tar -xzf {choco_version}.tar.gz").read() +run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet") +run(f"tar -xzf {choco_version}.tar.gz") print("choco tar unpack successful") os.chdir("choco-1.1.0") -os.popen("./build.sh").read() +run("./build.sh") -os.popen("cp -r build_output/chocolatey /opt/chocolatey").read() +run("cp -r build_output/chocolatey /opt/chocolatey") os.chdir(BLENDERBIM_DIR) if pathlib.Path("/opt/chocolatey/choco.exe").exists(): @@ -215,11 +220,15 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists(): print("\n_____ build choco pack") -os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read() -os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read() +run("mono /opt/chocolatey/choco.exe pack --allow-unofficial") +run( + 'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial' +) print("\n_____ build choco push") -os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read() +run( + 'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose' +) print(f"choco push of version: {target_release_tag} successful!") print(f"it took: {datetime.datetime.now() - start}") diff --git a/nix/build-all.py b/nix/build-all.py index 46b7b5c30c..4858907ce6 100644 --- a/nix/build-all.py +++ b/nix/build-all.py @@ -1,4 +1,6 @@ #!/usr/bin/python +# /// script +# /// ############################################################################### # # # This file is part of IfcOpenShell. # @@ -126,13 +128,7 @@ from collections.abc import Generator, Sequence from pathlib import Path from urllib.request import urlretrieve -try: - from typing import Literal, Union -except: - # python 3.6 compatibility for rocky 8 - from typing import Union - - from typing_extensions import Literal +from typing import Literal, Union logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -1094,10 +1090,19 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag f"http://www.python.org/ftp/python/{PYTHON_VERSION}/", f"Python-{PYTHON_VERSION}.tgz", ) - python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3" + python_install = INSTALL_DIR / f"python-{PYTHON_VERSION}" + python_bin = python_install / "bin" / "python3" # `_ssl` module is present -> we will be able to install `numpy` later # to verify IfcOpenShell installation - run([str(python_bin), "-c", "import _ssl"]) + try: + run([str(python_bin), "-c", "import _ssl"]) + except RuntimeError: + print( + "ERROR: Python was built without SSL support (_ssl module is missing). " + f"To fix this: remove the installed Python at {python_install}; " + "install OpenSSL development libraries and re-run." + ) + raise if MAC_CROSS_COMPILE_INTEL: assert original_path @@ -1515,7 +1520,7 @@ if "IfcOpenShell-Python" in targets: ) # Copy setup.py where pyodide build system expects it. shutil.copy(REPO_PATH / "pyodide" / "setup.py", REPO_PATH) - # Empty pyproject so it's contents won't affect the resulting wheelthe the + # Empty pyproject so it's contents won't affect the resulting wheel # otherwise the wheel will use version and dependencies from toml, not setup.py. (REPO_PATH / "pyproject.toml").write_text("") diff --git a/nix/cache_dependencies.py b/nix/cache_dependencies.py index 465d6002f1..7d231779ee 100644 --- a/nix/cache_dependencies.py +++ b/nix/cache_dependencies.py @@ -1,3 +1,5 @@ +# /// script +# /// """ Cache built dependencies for builds. @@ -41,6 +43,9 @@ def pack_dependencies(install_dir: Path) -> None: if not dependency_path.is_dir(): continue dependency_name = dependency_path.name + # Skip ifcopenshell - it's a build output, not a dependency to reuse across builds. + if dependency_name == "ifcopenshell": + continue tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz" if tar_path.exists(): print(f"Skipping existing cache: '{tar_path}'") diff --git a/pyodide/build_pyodide.sh b/pyodide/build_pyodide.sh index 20ad946162..8afbb3fcce 100755 --- a/pyodide/build_pyodide.sh +++ b/pyodide/build_pyodide.sh @@ -1,6 +1,11 @@ #!/usr/bin/bash set -ex +PYODIDE_VERSION=0.29.3 +PYODIDE_BUILD_VERSION=0.33.0 +PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}" +PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}" + # Script is assuming that it will be possible to execute it multiple times # therefore we're clearing venv each time and ignoring existing 'emsdk' folder. @@ -11,21 +16,15 @@ source .venv/bin/activate # Install pyodide cross build environment. # Instructions: https://pyodide.org/en/stable/development/building-packages.html -uv pip install pyodide-build +uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}" # `uv run` is required, so xbuildenv would skip using `pip`. -uv run pyodide xbuildenv install +uv run pyodide xbuildenv install "${PYODIDE_VERSION}" +uv run pyodide xbuildenv install-emscripten -# Emscripten doesn't come with xbuildenv. -if [ ! -d emsdk ]; then - git clone https://github.com/emscripten-core/emsdk -fi -pushd emsdk -PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) -./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION} -./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION} -source emsdk_env.sh +EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk" +source "${EMSDK_ROOT}/emsdk_env.sh" which emcc -popd +emcc --version mkdir -p packages/ifcopenshell VERSION=`cat IfcOpenShell/VERSION` diff --git a/pyodide/pack_wheel.py b/pyodide/pack_wheel.py new file mode 100644 index 0000000000..7b6c2a63d5 --- /dev/null +++ b/pyodide/pack_wheel.py @@ -0,0 +1,232 @@ +# +# /// script +# # Latest Pyodide build env versions are listed here: +# # https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json +# # https://github.com/pyodide/pyodide-build/blob/main/pyodide_build/xbuildenv_releases.py +# requires-python = "==3.13.2" +# dependencies = [ +# "requests", +# "setuptools", +# ] +# /// +""" +Pack an IfcOpenShell WASM wheel using Pyodide build system. + +Usage: + uv run make_wheel.py # Show this help + uv run make_wheel.py --build # Build wheel + uv run make_wheel.py --clean # Clean build artifacts and exit +""" + +import argparse +import os +import re +import shutil +import subprocess +import time +import zipfile +from pathlib import Path +from urllib.parse import quote + +import requests + +# Get repo root (parent of this script's parent directory) +REPO_ROOT = Path(__file__).parent.parent +PYODIDE_DIR = REPO_ROOT / "pyodide" +BUILD_DIR = PYODIDE_DIR / "build" + +# Hardcoded path (Windows packing workaround with --dev flag) +PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build") + +# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs) +WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32" + +# Location where ifcopenshell will be extracted +IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell" + + +class WheelBuilder: + @staticmethod + def extract_ifcopenshell_from_git(dst: Path) -> None: + """Extract ifcopenshell directory from git repo into destination.""" + Tools.rmrf(dst) + + print(f"Extracting ifcopenshell from git to {dst}...") + # Use git ls-files piped to git checkout-index to avoid copying + # untracked or ignored files from the actual repo. + ls_proc = subprocess.Popen( + ["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"], + cwd=REPO_ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + checkout_proc = subprocess.Popen( + ["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"], + cwd=REPO_ROOT, + stdin=ls_proc.stdout, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + assert ls_proc.stdout is not None + ls_proc.stdout.close() + checkout_proc.communicate() + + if checkout_proc.returncode != 0: + assert checkout_proc.stderr is not None + raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}") + + # Move src/ifcopenshell-python/ifcopenshell to ifcopenshell. + temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell" + shutil.move(temp_src, dst) + + # Clean up temporary src directory. + Tools.rmrf(PYODIDE_DIR / "src") + + print("āœ“ Extracted ifcopenshell from git") + + @staticmethod + def get_wheel_url(makefile_path: Path) -> str: + """Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile.""" + + def parse_makefile_vars() -> dict[str, str]: + content = makefile_path.read_text() + vars: dict[str, str] = {} + for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE): + vars[match.group(1)] = match.group(2).strip() + return vars + + vars: dict[str, str] = parse_makefile_vars() + binary_version = vars["BINARY_VERSION"] + build_commit = vars["BUILD_COMMIT"] + filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl" + encoded_filename = quote(filename, safe="") + return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}" + + @staticmethod + def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]: + """Download wheel from URL and extract .so and .py files.""" + py_wrapper_filename = "ifcopenshell_wrapper.py" + build_dir.mkdir(parents=True, exist_ok=True) + + wheel_path = build_dir / url.rsplit("/", 1)[-1] + + if wheel_path.exists(): + print(f"Using cached wheel: {wheel_path}") + else: + print(f"Downloading {url}...") + response = requests.get(url) + response.raise_for_status() + wheel_path.write_bytes(response.content) + + print("Extracting _ifcopenshell_wrapper files...") + with zipfile.ZipFile(wheel_path) as zf: + so_files = [f for f in zf.namelist() if f.endswith(".so")] + py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)] + + assert so_files, "No .so file found in wheel" + assert py_files, f"No {py_wrapper_filename} file found in wheel" + + so_file = so_files[0] + so_dst = build_dir / Path(so_file).name + so_dst.write_bytes(zf.read(so_file)) + + py_file = py_files[0] + py_dst = build_dir / Path(py_file).name + py_dst.write_bytes(zf.read(py_file)) + + return so_dst, py_dst + + +class Tools: + @staticmethod + def run( + cmd: list[str], + cwd: Path | None = None, + ) -> None: + print(f"$ {' '.join(cmd)}") + subprocess.check_call(cmd, cwd=cwd) + + @staticmethod + def create_symlink(dst: Path, src: Path) -> None: + Tools.rmrf(dst) + dst.symlink_to(src) + + @staticmethod + def rmrf(path: Path) -> None: + if path.exists() or path.is_symlink(): + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + else: + path.unlink() + + +def clean() -> None: + """Remove build artifacts.""" + paths_to_remove = ( + BUILD_DIR, + PYODIDE_DIR / ".pyodide_build", + PYODIDE_DIR / "dist", + PYODIDE_DIR / "ifcopenshell.egg-info", + PYODIDE_DIR / "src", + IFCOPENSHELL_DIR, + ) + for path in paths_to_remove: + if path.exists() or path.is_symlink(): + print(f"Removing {path}...") + Tools.rmrf(path) + print("āœ“ Clean complete") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, add_help=False) + parser.add_argument("--build", action="store_true", help="Build the wheel") + parser.add_argument("--clean", action="store_true", help="Clean build folder") + parser.add_argument( + "--dev", + action="store_true", + help="Use editable pyodide-build from hardcoded path (Windows packing workaround)", + ) + args = parser.parse_args() + + if not args.build and not args.clean: + print(__doc__) + return + + if args.clean: + clean() + return + + start_time = time.time() + + WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR) + + print("Downloading and extracting _ifcopenshell_wrapper files...") + makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile" + wheel_url = WheelBuilder.get_wheel_url(makefile) + so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR) + + Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file) + Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file) + + print("Installing pyodide-build...") + if args.dev: + Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)]) + else: + Tools.run(["uv", "pip", "install", "pyodide-build"]) + + print("Building with pyodide...") + # Use --no-isolation due to pyodide-build Windows support issues: + # symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`. + # Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows. + # + # Use `LEGACY_PLATFORM` since pyodide 0.34.1 introduced new tag for wheels `pyemscripten`, + # which doesn't work with pyodide itself yet - https://github.com/pyodide/pyodide/issues/6177. + os.environ["USE_LEGACY_PLATFORM"] = "1" + Tools.run(["pyodide", "build", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"]) + + elapsed = time.time() - start_time + print(f"\nāœ“ Done! ({elapsed:.1f}s)") + + +if __name__ == "__main__": + main() diff --git a/pyodide/setup.py b/pyodide/setup.py index 9678de0ac3..474a0b45a4 100644 --- a/pyodide/setup.py +++ b/pyodide/setup.py @@ -2,12 +2,16 @@ # because `tool.setuptools.ext-modules` is still experimental in pyproject.toml # and we need it to get the wheel suffix right. import os +import sys from pathlib import Path import tomllib from setuptools import Extension, find_packages, setup +from setuptools.command.build_ext import build_ext -REPO_FOLDER = Path(__file__).parent +# Detect repo folder: if setup.py is in pyodide folder, go to parent +SETUP_DIR = Path(__file__).parent +REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR def get_version() -> str: @@ -25,6 +29,39 @@ def get_dependencies() -> list[str]: return dependencies +class UnixBuildExt(build_ext): + """Customize ``build_ext`` to support packing on Windows.""" + + def finalize_options(self): + from distutils import sysconfig + + super().finalize_options() + if sys.platform == "win32": + self.compiler = "unix" + + # Configure sysconfig for Windows builds + # CCSHARED is the only variable that's not customizable with env vars. + # Basically avoiding this: + # File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler + # compiler_so=cc_cmd + ' ' + ccshared, + # ~~~~~~~~~~~~~^~~~~~~~~~ + # TypeError: can only concatenate str (not "NoneType") to str + sysconfig.get_config_vars() # Initialize config cache + if sysconfig._config_vars.get("CCSHARED") is None: + sysconfig._config_vars["CCSHARED"] = "-fPIC" + # Override compiler type before it's instantiated + + # Set Emscripten compiler environment variables + os.environ["CC"] = "emcc" + os.environ["CXX"] = "em++" + os.environ["CFLAGS"] = "" + os.environ["CXXFLAGS"] = "" + os.environ["LDSHARED"] = "emcc -shared" + os.environ["AR"] = "emar" + os.environ["ARFLAGS"] = "rcs" + os.environ["SETUPTOOLS_EXT_SUFFIX"] = ".cpython-313-wasm32-emscripten.so" + + setup( name="ifcopenshell", version=get_version(), @@ -44,4 +81,5 @@ setup( }, # Has to provide extension to get the correct wheel suffix. ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])], + cmdclass={"build_ext": UnixBuildExt}, ) diff --git a/pyproject.toml b/pyproject.toml index 7234815c08..37aed2dded 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,9 @@ name = "IfcOpenShell" version = "0.0.0" dependencies = [ "black==26.3.1", - "ruff==0.15.6", + "ruff==0.15.12", "poethepoet", + "ty==0.0.32", "gersemi==0.26.1", ] @@ -28,6 +29,9 @@ extend-exclude = ''' reportInvalidTypeForm = false disableBytesTypePromotions = true reportUnnecessaryTypeIgnoreComment = true +reportRedeclaration = false +# Ignore warnings from bpy stubs missing actual source files. +reportMissingModuleSource = false # Pylance doesn't respect gitignore, so we have to exclude files manually here # to avoid VS Code slowing down. # https://github.com/microsoft/pylance-release/issues/5169 @@ -78,15 +82,184 @@ ignore = [ "UP032", # Replace .format with f-string ] +[tool.ty.rules] +all = "ignore" + +# Structural rules (no deep type inference needed, easier to adapt). +abstract-method-in-final-class = "error" +ambiguous-protocol-member = "error" +conflicting-declarations = "error" +conflicting-metaclass = "error" +cyclic-class-definition = "error" +cyclic-type-alias-definition = "error" +dataclass-field-order = "error" +duplicate-base = "error" +duplicate-kw-only = "error" +empty-body = "error" +escape-character-in-forward-annotation = "error" +final-on-non-method = "error" +final-without-value = "error" +ignore-comment-unknown-rule = "error" +implicit-concatenated-string-type-annotation = "error" +inconsistent-mro = "error" +ineffective-final = "error" +instance-layout-conflict = "error" +invalid-dataclass = "error" +invalid-dataclass-override = "error" +invalid-enum-member-annotation = "error" +invalid-explicit-override = "error" +invalid-frozen-dataclass-subclass = "error" +invalid-generic-class = "error" +invalid-generic-enum = "error" +invalid-ignore-comment = "error" +invalid-legacy-positional-parameter = "error" +invalid-legacy-type-variable = "error" +invalid-named-tuple = "error" +invalid-newtype = "error" +invalid-overload = "error" +invalid-paramspec = "error" +invalid-protocol = "error" +invalid-syntax-in-forward-annotation = "error" +invalid-total-ordering = "error" +invalid-type-alias-type = "error" +invalid-type-checking-constant = "error" +invalid-type-guard-definition = "error" +invalid-type-variable-bound = "error" +invalid-type-variable-constraints = "error" +invalid-typed-dict-header = "error" +invalid-typed-dict-statement = "error" +override-of-final-method = "error" +override-of-final-variable = "error" +possibly-missing-import = "error" +possibly-missing-submodule = "error" +# Has false positives due to ty walrus operator bug. +# possibly-unresolved-reference = "error" +raw-string-type-annotation = "error" +redundant-final-classvar = "error" +shadowed-type-variable = "error" +subclass-of-final-class = "error" +super-call-in-named-tuple-method = "error" +unavailable-implicit-super-arguments = "error" +unbound-type-variable = "error" +undefined-reveal = "error" +unresolved-global = "error" +unresolved-import = "error" +unresolved-reference = "error" +unused-ignore-comment = "error" +unused-type-ignore-comment = "error" +useless-overload-body = "error" + +# Non-structural rules: +deprecated = "error" +zero-stepsize-in-slice = "error" +possibly-missing-implicit-call = "error" +unused-awaitable = "error" + +# Function argument rules: +# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module. +# call-non-callable = "error" +conflicting-argument-forms = "error" +# Too many false positives. +# invalid-argument-type = "error" +missing-argument = "error" +parameter-already-assigned = "error" +positional-only-parameter-as-kwarg = "error" +too-many-positional-arguments = "error" +unknown-argument = "error" +# Has a lot of warnings due to current ty walrus operator issues. +# index-out-of-bounds = "error" +# unresolved-attribute = "error" + +[tool.ty.environment] +extra-paths = [ + "src/bonsai/external_dependencies", + "src/bcf", + "src/bsdd", + "src/bonsai", + "src/ifc4d", + "src/ifc5d", + "src/ifccityjson", + "src/ifcclash", + "src/ifccsv", + "src/ifcdiff", + "src/ifcfm", + "src/ifcopenshell-python", + "src/ifcpatch", + "src/ifctester", +] + +[tool.ty.src] +exclude = [ + # External dependencies cloned for type checking only. + "src/bonsai/external_dependencies", + # Submodules. + "src/ifcopenshell-python/ifcopenshell/express", + "src/ifcopenshell-python/ifcopenshell/mvd", + "src/ifcopenshell-python/ifcopenshell/simple_spf", + "src/svgfill/3rdparty", + # Has special dependencies. + "src/ifcopenshell-python/ifcopenshell/geom/app.py", + "src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py", + "src/ifcopenshell-python/ifcopenshell/util/doc.py", + "src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py", + "src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py", + # Too esoteric. + "src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py", + "src/ifc2ca/templates", + # Too dev. + "src/bcf/setup.py", + "src/bsdd/yml_to_classes.py", + # Deprecated. + "src/ifc2ca/_deprecated", +] + [tool.poe.tasks] -ruff-main = "ruff check --extend-exclude nix/build-all.py" -# It's actually Python 3.6, but ruff only supports 3.7+, but it should do. -ruff-old = "ruff check nix/build-all.py --target-version py37" -ruff.sequence = ["ruff-main", "ruff-old"] +ruff = "ruff check" black = "black ." -format.sequence = ["black", "ruff-main", "ruff-old"] +ty.sequence = ["ty-bonsai", "ty-ios"] +ty.help = "Run ty type checker. Requires ty-venv to be set up first." +ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv" + +ty-venv.sequence = ["bonsai-deps", "ty-venv-bonsai", "ty-venv-ios"] + +ty-venv-bonsai.sequence = [ + {cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"}, + {cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"}, +] + +ty-venv-ios.sequence = [ + {cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"}, + {cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"}, +] + +format.sequence = ["black", "ruff"] cmake-format = "gersemi . --in-place" + +[tool.poe.tasks.ty-ios] +# --ignore unresolved-reference: walrus operator false positives in ty. +cmd = """ + ty check + src/bcf + src/bsdd + src/ifc2ca + src/ifc4d + src/ifc5d + src/ifccityjson + src/ifcclash + src/ifccsv + src/ifcdiff + src/ifcfm + src/ifcopenshell-python + src/ifcpatch + src/ifctester + --python=src/ifcopenshell-python/.venv + --ignore unresolved-reference +""" + +[tool.poe.tasks.bonsai-deps] +help = "Clone or update Bonsai external dependencies." +cmd = "python src/bonsai/scripts/bonsai_deps.py" diff --git a/src/bcf/bcf/v3/bcfapi.py b/src/bcf/bcf/v3/bcfapi.py index 3ac85b9685..368c294cd0 100644 --- a/src/bcf/bcf/v3/bcfapi.py +++ b/src/bcf/bcf/v3/bcfapi.py @@ -34,8 +34,8 @@ client_id, client_secret = "", "" class OAuthReceiver(http.server.BaseHTTPRequestHandler): def do_GET(self) -> None: query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query) - self.server.auth_code = query.get("code", [""])[0] # type: ignore - self.server.auth_state = query.get("state", [""])[0] # type: ignore + self.server.auth_code = query.get("code", [""])[0] + self.server.auth_state = query.get("state", [""])[0] self.send_response(200) self.send_header("Content-type", "text/plain") self.end_headers() @@ -255,7 +255,7 @@ class BcfClient: project_id: str = "", topics: str = "", query_string: Optional[str] = None, - ) -> list[Any]: + ) -> None: # return self.get( # f"/projects/{project_id}/topics", # { diff --git a/src/bcf/tests/v3/test_example_files.py b/src/bcf/tests/v3/test_example_files.py index 3f141afda6..06eb5d22dd 100644 --- a/src/bcf/tests/v3/test_example_files.py +++ b/src/bcf/tests/v3/test_example_files.py @@ -173,16 +173,17 @@ def assert_viewpoints(viewpoints): assert viewpoint.snapshot is not None +# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: expected_vp = mdl.VisualizationInfo( components=mdl.Components( - view_setup_hints=mdl.ViewSetupHints( - spaces_visible=False, - space_boundaries_visible=False, - openings_visible=False, - ), selection=expected_selection, visibility=mdl.ComponentVisibility( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=False, + ), exceptions=expected_exception, default_visibility=False, ), @@ -193,6 +194,7 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266), camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048), field_of_view=60, + aspect_ratio=1.0, ), guid="21dd4807-e9af-439e-a980-04d913a6b1ce", ) @@ -200,16 +202,17 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e assert viewpoint.snapshot is not None +# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None: expected_vp = mdl.VisualizationInfo( components=mdl.Components( - view_setup_hints=mdl.ViewSetupHints( - spaces_visible=False, - space_boundaries_visible=False, - openings_visible=True, - ), selection=expected_selection, visibility=mdl.ComponentVisibility( + view_setup_hints=mdl.ViewSetupHints( + spaces_visible=False, + space_boundaries_visible=False, + openings_visible=True, + ), exceptions=expected_exception, default_visibility=True, ), @@ -220,6 +223,7 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428), camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241), field_of_view=60, + aspect_ratio=1.0, ), guid="81daa431-bf01-4a49-80a2-1ab07c177717", ) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index 3cbfd8bdbf..e00d77da8e 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -17,8 +17,8 @@ # along with Bonsai. If not, see . SHELL := sh -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 PATCH:=patch SED:=sed -i VENV_ACTIVATE:=bin/activate @@ -48,6 +48,7 @@ VERSION_PATCH:=$(shell cat '../../VERSION' | cut -d '.' -f 3) VERSION_DATE:=$(shell date '+%y%m%d') LAST_COMMIT_HASH:=$(shell git rev-parse HEAD) LAST_COMMIT_DATE:=$(shell git show -s --format=%cI) +LAST_GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD) PYPI_IMP:=cp ifdef PYVERSION @@ -63,6 +64,7 @@ PYNUMBER:=3$(PYMINOR) PYPI_VERSION:=3.$(PYMINOR) endif # def PYVERSION +IFCMERGE_VERSION:=2026-04-07 ifdef PLATFORM SUPPORTED_PLATFORMS := linux macos macosm1 win @@ -232,18 +234,16 @@ endif cd build/bonsai/bim/data/brick/ && wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl # Required for hipped roof generation - # TODO: Use official repo once https://github.com/prochitecture/bpypolyskel/pull/22 is merged. - cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/Andrej730/bpypolyskel.git@pyproject_toml" --no-deps -w wheels/ + cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/prochitecture/bpypolyskel" --no-deps -w wheels/ # folder for executable files mkdir -p build/bonsai/libs/bin # required for three-way git merging ifeq ($(PLATFORM), win) - cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/2025-01-26/ifcmerge.zip - cd build/bonsai/libs/bin && unzip ifcmerge.zip && rm ifcmerge.zip + cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(IFCMERGE_VERSION)/ifcmerge.exe else - cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/main/ifcmerge && chmod +x ifcmerge + cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/$(IFCMERGE_VERSION)/ifcmerge && chmod +x ifcmerge endif # Generate translations module for Bonsai build @@ -262,6 +262,7 @@ else $(SED) "s/0.0.0/$(VERSION)-alpha$(VERSION_DATE)/" build/bonsai/blender_manifest.toml $(SED) "s/8888888/$(LAST_COMMIT_HASH)/" build/bonsai/__init__.py $(SED) "s/9999999/$(LAST_COMMIT_DATE)/" build/bonsai/__init__.py + $(SED) "s/7777777/$(LAST_GIT_BRANCH)/" build/bonsai/__init__.py $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' build/pyproject.toml endif diff --git a/src/bonsai/bonsai/__init__.py b/src/bonsai/bonsai/__init__.py index 915071855f..c6ec6405c0 100644 --- a/src/bonsai/bonsai/__init__.py +++ b/src/bonsai/bonsai/__init__.py @@ -43,6 +43,7 @@ from typing import TYPE_CHECKING, Any, Union last_commit_hash = "8888888" last_commit_date = "9999999" +last_git_branch = "7777777" def get_last_commit_hash() -> Union[str, None]: @@ -60,6 +61,15 @@ def get_last_commit_date() -> Union[str, None]: return last_commit_date +def get_git_branch() -> Union[str, None]: + # Using this weird way to write 7777777, + # so makefile won't accidentally replace it here + # we'll be able to distinguish branch from placeholder value. + if last_git_branch == str(7_777777): + return None + return last_git_branch + + # Accessed from bonsai extension: bbim_semver: dict[str, Any] = {} @@ -125,6 +135,7 @@ def get_debug_info(*, bonsai_failed_to_load: bool = False) -> dict[str, Any]: "bonsai_version": bbim_version, "bonsai_commit_hash": get_last_commit_hash(), "bonsai_commit_date": get_last_commit_date(), + "bonsai_git_branch": get_git_branch(), "last_actions": last_actions, "last_error": last_error, } @@ -251,10 +262,12 @@ if IN_BLENDER: global last_commit_hash global last_commit_date + global last_git_branch path = Path(__file__).resolve().parent repo = git.Repo(str(path), search_parent_directories=True) last_commit_hash = repo.head.object.hexsha last_commit_date = repo.head.object.committed_datetime.isoformat() + last_git_branch = repo.active_branch.name except: pass diff --git a/src/bonsai/bonsai/bim/data/fonts/LICENSE b/src/bonsai/bonsai/bim/data/fonts/LICENSE new file mode 100644 index 0000000000..4dc4bdd093 --- /dev/null +++ b/src/bonsai/bonsai/bim/data/fonts/LICENSE @@ -0,0 +1,96 @@ +Copyright (c) 2011-2012, Nikita Volchenkov (), +with Reserved Font Name OpenGost Type B. + +Copyright (c) 2012, Valek Filippov (). + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc index f3c2c0277e..e3ff0d4bbf 100644 --- a/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc +++ b/src/bonsai/bonsai/bim/data/pset/Psets_BBIM_Annotation.ifc @@ -5,7 +5,7 @@ FILE_NAME('Psets_BBIM_Annotation.ifc','2020-01-01T00:00:00',$,$,'Psets_BBIM_Anno FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#34,#35)); +#1=IFCPROPERTYSETTEMPLATE('3VuPUwdCD2Qx3XDDRs0R1N',$,'EPset_Annotation','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation,IfcTypeProduct',(#4,#33,#29,#32,#3,#2,#41,#42)); #2=IFCSIMPLEPROPERTYTEMPLATE('2P7JN79n96Q9pElZ83LKe4',$,'ZIndex','',.P_SINGLEVALUE.,'IfcInteger',$,$,$,$,$,.READWRITE.); #3=IFCSIMPLEPROPERTYTEMPLATE('1Wpx_r2xj1_9w5JpI0QRJy',$,'Symbol','',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #4=IFCSIMPLEPROPERTYTEMPLATE('3q0oxMUKP47vZ4jnyG$dDb',$,'Classes','Classes separated by spaces that end up in classes for this element in svg. Can be used to specify the text font size: small - 1.8mm; regular - 2.5mm; large - 3.5mm; header - 5mm; title - 7mm. By default regular size is used.',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -28,7 +28,7 @@ DATA; #21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); #23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); -#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30)); +#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcAnnotation/ANGLE,IfcAnnotation/PLAN_LEVEL,IfcAnnotation/SECTION_LEVEL,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34,#37,#38,#39,#40)); #25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); @@ -38,7 +38,14 @@ DATA; #31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$); #32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); -#34=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); -#35=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); +#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#37=IFCSIMPLEPROPERTYTEMPLATE('1XpRnKoT2sGuW7vYcZaMqb',$,'Anchors','JSON array of parametric anchor descriptors — one per polyline vertex. Each entry: {"guid": str|null, "type": "FACE"|"CIRCLE_CENTER"|"WORLD", "addr": {...}, "hint": [x,y,z]|null, "pt": [x,y,z]}',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#38=IFCSIMPLEPROPERTYTEMPLATE('2YqSmLoU3tHvX8wZdaNrjc',$,'MeasureAxis','Axis along which distances are projected: X | Y | Z | TRUE | PERPENDICULAR',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.); +#39=IFCSIMPLEPROPERTYTEMPLATE('3Ny31Go6T5Z9fh8j4yQC0p',$,'ForcePerpendicularToFace','When enabled the polyline is constrained to follow the face normal of the first anchor vertex so the dimension measures straight-line distance perpendicular to that face',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#40=IFCSIMPLEPROPERTYTEMPLATE('1LoNpKqR3sTuVwXyZaBcDe',$,'LinePosition','Absolute world-space coordinate (metres) of the dimension line along the horizontal offset axis (perpendicular to the dimension direction). When set, the dimension line is held at this fixed global position even if the measured geometry moves. When absent the line sits at the anchor points.',.P_SINGLEVALUE.,'IfcLengthMeasure',$,$,$,$,$,.READWRITE.); +#41=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsAnnotFaux0001aB',$,'IsManualDrawingReference','Marks this annotation as a manually placed drawing reference, exempt from automatic drawing regeneration.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#42=IFCSIMPLEPROPERTYTEMPLATE('0FauxIsDocRefFaux001aB',$,'IsDocumentReference','Marks this annotation as pointing to an external document reference (not a Bonsai drawing camera).',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); ENDSEC; END-ISO-10303-21; diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index 633d6292f9..f28c106e95 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -72,9 +72,7 @@ class IfcExporter: def set_header(self): self.file.header.file_name.name = os.path.basename(self.ifc_export_settings.output_file) - self.file.header.file_name.time_stamp = ( - datetime.datetime.utcnow().replace(tzinfo=datetime.UTC).astimezone().replace(microsecond=0).isoformat() - ) + self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() self.file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version) self.file.header.file_name.originating_system = "{} {}".format( self.get_application_name(), tool.Blender.get_bonsai_version() diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index 231b44c671..e11eb07ce8 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -45,16 +45,19 @@ from bonsai.bim.module.nest.decorator import NestDecorator cwd = os.path.dirname(os.path.realpath(__file__)) global_subscription_owner = object() +# Separate owner for per-object msgbus subscriptions (name, active_material_index). +# Using a dedicated owner allows clearing all per-object subscriptions at once +# during undo/redo without affecting other global subscriptions. +object_subscription_owner = object() def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None: try: obj.name except: - # The object is invalid but somehow still has a callback. Clear all - # msgbus subscriptions to prevent useless further triggers. - bpy.msgbus.clear_by_owner(obj) - return # In case the object RNA is gone during an undo / redo operation + # The object is invalid but somehow still has a callback. + # This can occur during undo/redo when the Python wrapper is stale. + return # Blender names are up to 63 UTF-8 bytes if len(bytes(obj.name, "utf-8")) >= 63: return @@ -189,7 +192,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type return bpy.msgbus.subscribe_rna( key=subscribe_to, - owner=obj, + owner=object_subscription_owner, args=( obj, data_path, diff --git a/src/bonsai/bonsai/bim/ifc.py b/src/bonsai/bonsai/bim/ifc.py index f7d23e1dbe..b07e584a71 100644 --- a/src/bonsai/bonsai/bim/ifc.py +++ b/src/bonsai/bonsai/bim/ifc.py @@ -316,11 +316,8 @@ class IfcStore: del IfcStore.id_map[data["id"]] if "guid" in data: del IfcStore.guid_map[data["guid"]] - obj = IfcStore.get_object_by_name(data["obj"]) - if obj is None: - # obj was just created during this step and didn't existed before. - return - bpy.msgbus.clear_by_owner(obj) + # Note: msgbus subscriptions are cleared globally during + # rebuild_element_maps which runs after every undo/redo. @staticmethod def commit_link_element(data: OperationData) -> None: @@ -367,10 +364,8 @@ class IfcStore: del IfcStore.id_map[data["id"]] if "guid" in data: del IfcStore.guid_map[data["guid"]] - obj = IfcStore.get_object_by_name(data["obj"]) - # obj might be removed after unlink. - if not obj: - bpy.msgbus.clear_by_owner(obj) + # Note: msgbus subscriptions are cleared globally during + # rebuild_element_maps which runs after every undo/redo. @staticmethod def unlink_element( diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 11b92be532..267800469a 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -64,8 +64,8 @@ class MaterialCreator: mesh: Union[OBJECT_DATA_TYPE, None], shape_has_openings: bool, ) -> None: - if ((rep := getattr(element, "Representation", ...) is not ...) and not rep) or ( - (rep := getattr(element, "RepresentationMaps", ...) is not ...) and not rep + if ((rep := getattr(element, "Representation", ...)) is not ... and not rep) or ( + (rep := getattr(element, "RepresentationMaps", ...)) is not ... and not rep ): return diff --git a/src/bonsai/bonsai/bim/module/aggregate/prop.py b/src/bonsai/bonsai/bim/module/aggregate/prop.py index 0595c200c5..424f2b829f 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/prop.py +++ b/src/bonsai/bonsai/bim/module/aggregate/prop.py @@ -73,6 +73,22 @@ def poll_related_object(self: "BIMObjectAggregateProperties", related_obj: bpy.t return True +def update_relating_object(self, context): + if self.relating_object: + ifc_id = tool.Blender.get_object_bim_props(self.relating_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(relating_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + +def update_related_object(self, context): + if self.related_object: + ifc_id = tool.Blender.get_object_bim_props(self.related_object).ifc_definition_id + if ifc_id: + bpy.ops.bim.aggregate_assign_object(related_object=ifc_id) + bpy.ops.bim.disable_editing_aggregate() + + def update_aggregate_decorator(self, context): if self.aggregate_decorator: AggregateDecorator.install(bpy.context) @@ -89,12 +105,15 @@ def update_aggregate_mode_decorator(self, context): class BIMObjectAggregateProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") - relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object) + relating_object: PointerProperty( + name="Relating Whole", type=bpy.types.Object, poll=poll_relating_object, update=update_relating_object + ) related_object: PointerProperty( name="Related Part", description="Related Part, will be used to derive the Relating Object", type=bpy.types.Object, poll=poll_related_object, + update=update_related_object, ) if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/attribute/operator.py b/src/bonsai/bonsai/bim/module/attribute/operator.py index 13901c0f81..8069aa29ce 100644 --- a/src/bonsai/bonsai/bim/module/attribute/operator.py +++ b/src/bonsai/bonsai/bim/module/attribute/operator.py @@ -295,13 +295,13 @@ class ExplorerShowUIPopup(bpy.types.Operator): bl_description = "Show Explorer UI to select element as attribute value or edit it." bl_options = {"REGISTER", "UNDO"} - ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + ifc_class: bpy.props.StringProperty() """Element IFC class.""" - attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + attribute_name: bpy.props.StringProperty() """IFC class attribute name.""" - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path""" - preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) """IFC id to preselect in the popup.""" if TYPE_CHECKING: diff --git a/src/bonsai/bonsai/bim/module/attribute/prop.py b/src/bonsai/bonsai/bim/module/attribute/prop.py index acff2424b7..425c875e37 100644 --- a/src/bonsai/bonsai/bim/module/attribute/prop.py +++ b/src/bonsai/bonsai/bim/module/attribute/prop.py @@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup): class ExplorerEntity(PropertyGroup): - ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + ifc_definition_id: bpy.props.IntProperty() if TYPE_CHECKING: ifc_definition_id: int @@ -60,7 +60,7 @@ class BIMExplorerProperties(PropertyGroup): self.property_unset("editing_entity_id") self.entity_attributes.clear() - is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration] + is_loaded: BoolProperty( name="Toggle Explorer UI", update=update_is_loaded, ) @@ -76,15 +76,15 @@ class BIMExplorerProperties(PropertyGroup): def update_ifc_class(self, context: object) -> None: tool.Attribute.refresh_uilist_entities() - ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration] + ifc_class: EnumProperty( name="IFC Class To Search", items=get_ifc_class, update=update_ifc_class, ) - entities: CollectionProperty(type=ExplorerEntity) # pyright: ignore[reportRedeclaration] - active_entity_index: IntProperty() # pyright: ignore[reportRedeclaration] - editing_entity_id: IntProperty() # pyright: ignore[reportRedeclaration] - entity_attributes: CollectionProperty(type=Attribute) # pyright: ignore[reportRedeclaration] + entities: CollectionProperty(type=ExplorerEntity) + active_entity_index: IntProperty() + editing_entity_id: IntProperty() + entity_attributes: CollectionProperty(type=Attribute) if TYPE_CHECKING: is_loaded: bool diff --git a/src/bonsai/bonsai/bim/module/bcf/prop.py b/src/bonsai/bonsai/bim/module/bcf/prop.py index 3ac751387e..651c0c052f 100644 --- a/src/bonsai/bonsai/bim/module/bcf/prop.py +++ b/src/bonsai/bonsai/bim/module/bcf/prop.py @@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup): def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]: - global RELATED_TOPICS_ENUM_ITEMS + global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global] props = self active_topic = props.active_topic active_related_topics = active_topic.related_topics.keys() diff --git a/src/bonsai/bonsai/bim/module/boundary/operator.py b/src/bonsai/bonsai/bim/module/boundary/operator.py index 2c6a6de2cf..45ee8c60e2 100644 --- a/src/bonsai/bonsai/bim/module/boundary/operator.py +++ b/src/bonsai/bonsai/bim/module/boundary/operator.py @@ -377,6 +377,8 @@ class EnableEditingBoundary(bpy.types.Operator): obj = tool.Ifc.get_object(entity) if entity and obj: setattr(bprops, blender_property, obj) + bprops.physical_or_virtual = boundary.PhysicalOrVirtualBoundary or "NOTDEFINED" + bprops.internal_or_external = boundary.InternalOrExternalBoundary or "NOTDEFINED" return {"FINISHED"} @@ -392,6 +394,8 @@ class DisableEditingBoundary(bpy.types.Operator): bprops.is_editing = False for ifc_attribute, blender_property in EDITABLE_ATTRIBUTES.items(): setattr(bprops, blender_property, None) + bprops.physical_or_virtual = "NOTDEFINED" + bprops.internal_or_external = "NOTDEFINED" return {"FINISHED"} @@ -411,6 +415,8 @@ class EditBoundaryAttributes(bpy.types.Operator, tool.Ifc.Operator): obj = getattr(bprops, blender_property, None) entity = tool.Ifc.get_entity(obj) attributes[blender_property] = entity + attributes["physical_or_virtual"] = bprops.physical_or_virtual + attributes["internal_or_external"] = bprops.internal_or_external ifcopenshell.api.boundary.edit_attributes(tool.Ifc.get(), entity=boundary, **attributes) bpy.ops.bim.disable_editing_boundary() return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/boundary/prop.py b/src/bonsai/bonsai/bim/module/boundary/prop.py index 2e9ab8c975..7b4f5069ad 100644 --- a/src/bonsai/bonsai/bim/module/boundary/prop.py +++ b/src/bonsai/bonsai/bim/module/boundary/prop.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Union import bpy from bpy.props import ( BoolProperty, + EnumProperty, PointerProperty, ) from bpy.types import PropertyGroup @@ -50,12 +51,43 @@ def element_filter(self: "BIMObjectBoundaryProperties", object: bpy.types.Object return False +def get_internal_or_external_items( + self: "BIMObjectBoundaryProperties", context: bpy.types.Context | None +) -> list[tuple[str, str, str]]: + items = [ + ("INTERNAL", "Internal", ""), + ("EXTERNAL", "External", ""), + ] + ifc = tool.Ifc.get() + if not ifc or ifc.schema != "IFC2X3": + items += [ + ("EXTERNAL_EARTH", "External Earth", ""), + ("EXTERNAL_WATER", "External Water", ""), + ("EXTERNAL_FIRE", "External Fire", ""), + ] + items.append(("NOTDEFINED", "Not Defined", "")) + return items + + class BIMObjectBoundaryProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing") relating_space: PointerProperty(name="RelatingSpace", type=bpy.types.Object, poll=space_filter) related_building_element: PointerProperty(name="RelatedBuildingElement", type=bpy.types.Object, poll=element_filter) parent_boundary: PointerProperty(name="ParentBoundary", type=bpy.types.Object, poll=boundary_filter) corresponding_boundary: PointerProperty(name="CorrespondingBoundary", type=bpy.types.Object, poll=boundary_filter) + physical_or_virtual: EnumProperty( + name="PhysicalOrVirtualBoundary", + items=[ + ("PHYSICAL", "Physical", ""), + ("VIRTUAL", "Virtual", ""), + ("NOTDEFINED", "Not Defined", ""), + ], + default="NOTDEFINED", + ) + internal_or_external: EnumProperty( + name="InternalOrExternalBoundary", + items=get_internal_or_external_items, + ) if TYPE_CHECKING: is_editing: bool @@ -63,6 +95,8 @@ class BIMObjectBoundaryProperties(PropertyGroup): related_building_element: Union[bpy.types.Object, None] parent_boundary: Union[bpy.types.Object, None] corresponding_boundary: Union[bpy.types.Object, None] + physical_or_virtual: str + internal_or_external: str # values depend on schema: IFC2X3 omits EXTERNAL_EARTH/WATER/FIRE class BIMBoundaryProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/boundary/ui.py b/src/bonsai/bonsai/bim/module/boundary/ui.py index 0ca21b9ce5..91990a5eb0 100644 --- a/src/bonsai/bonsai/bim/module/boundary/ui.py +++ b/src/bonsai/bonsai/bim/module/boundary/ui.py @@ -77,6 +77,10 @@ class BIM_PT_Boundary(Panel): self.draw_relation_editor(boundary, "RelatedBuildingElement", "related_building_element") self.draw_relation_editor(boundary, "ParentBoundary", "parent_boundary") self.draw_relation_editor(boundary, "CorrespondingBoundary", "corresponding_boundary") + row = self.layout.row() + row.prop(self.bprops, "physical_or_virtual") + row = self.layout.row() + row.prop(self.bprops, "internal_or_external") else: row = self.layout.row() row.operator("bim.enable_editing_boundary", icon="GREASEPENCIL", text="Edit") @@ -84,6 +88,8 @@ class BIM_PT_Boundary(Panel): self.draw_relation_data(boundary, "RelatedBuildingElement") self.draw_relation_data(boundary, "ParentBoundary") self.draw_relation_data(boundary, "CorrespondingBoundary") + self.draw_enum_data(boundary, "PhysicalOrVirtualBoundary") + self.draw_enum_data(boundary, "InternalOrExternalBoundary") if hasattr(boundary, "InnerBoundaries"): for i, inner_boundary in enumerate(getattr(boundary, "InnerBoundaries", ())): row = self.layout.row(align=True) @@ -110,6 +116,11 @@ class BIM_PT_Boundary(Panel): else: row.label(text="") + def draw_enum_data(self, boundary, ifc_attribute: str): + row = self.layout.row(align=True) + row.label(text=ifc_attribute) + row.label(text=getattr(boundary, ifc_attribute, "") or "") + def draw_relation_editor(self, boundary, ifc_attribute: str, blender_property: str): if hasattr(boundary, ifc_attribute): row = self.layout.row(align=True) diff --git a/src/bonsai/bonsai/bim/module/brick/prop.py b/src/bonsai/bonsai/bim/module/brick/prop.py index 6201113a09..2d507411e4 100644 --- a/src/bonsai/bonsai/bim/module/brick/prop.py +++ b/src/bonsai/bonsai/bim/module/brick/prop.py @@ -46,26 +46,26 @@ def get_libraries(self, context): def get_namespaces(self, context): - global NAMESPACES_ENUM_ITEMS + global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global] NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces] return NAMESPACES_ENUM_ITEMS def get_brick_entity_classes(self, context): - global ENTITY_CLASSES_ENUM_ITEMS + global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global] entity = self.brick_entity_create_type ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]] return ENTITY_CLASSES_ENUM_ITEMS def get_brick_roots(self, context): - global BRICK_ROOTS_ENUM_ITEMS + global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global] BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes] return BRICK_ROOTS_ENUM_ITEMS def get_brick_relations(self, context): - global BRICK_RELATIONS_ENUM_ITEMS + global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global] BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships] for relation in BrickschemaData.data["active_relations"]: if relation["predicate_name"] == "label": diff --git a/src/bonsai/bonsai/bim/module/clash/operator.py b/src/bonsai/bonsai/bim/module/clash/operator.py index 3788130a12..ae5f622bbd 100644 --- a/src/bonsai/bonsai/bim/module/clash/operator.py +++ b/src/bonsai/bonsai/bim/module/clash/operator.py @@ -201,16 +201,10 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper): "ALT+click to run a quick clash without selecting a file to save." ) - filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] - default="*.bcf;*.json", options={"HIDDEN"} - ) - format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] - name="Format", items=[(i, i, "") for i in ("bcf", "json")] - ) - filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] - subtype="FILE_PATH", options={"SKIP_SAVE"} - ) - quick_clash: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"}) + format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")]) + filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"}) + quick_clash: bpy.props.BoolProperty( options={"SKIP_SAVE"}, ) diff --git a/src/bonsai/bonsai/bim/module/clash/prop.py b/src/bonsai/bonsai/bim/module/clash/prop.py index 8bcd71632b..1ef3403e64 100644 --- a/src/bonsai/bonsai/bim/module/clash/prop.py +++ b/src/bonsai/bonsai/bim/module/clash/prop.py @@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty class ClashSource(PropertyGroup): - name: StringProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty( name="File", description="Absolute filepath to existing .ifc file to use as a clash source.", ) - filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration] - mode: EnumProperty( # pyright: ignore[reportRedeclaration] + filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") + mode: EnumProperty( items=[ ("a", "All Elements", "All elements will be used for clashing"), ("i", "Include", "Only the selected elements are included for clashing"), @@ -62,7 +62,7 @@ class Clash(PropertyGroup): b_global_id: StringProperty(name="B") a_name: StringProperty(name="A Name") b_name: StringProperty(name="B Name") - clash_type: EnumProperty( # pyright: ignore[reportRedeclaration] + clash_type: EnumProperty( name="Clash Type", items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS), ) diff --git a/src/bonsai/bonsai/bim/module/cost/operator.py b/src/bonsai/bonsai/bim/module/cost/operator.py index b612100a1d..ac9d2d4bf1 100644 --- a/src/bonsai/bonsai/bim/module/cost/operator.py +++ b/src/bonsai/bonsai/bim/module/cost/operator.py @@ -87,7 +87,7 @@ class CopyCostSchedule(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Cost Schedule" bl_description = "Create a duplicate of the provided cost schedule." bl_options = {"REGISTER", "UNDO"} - cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + cost_schedule: bpy.props.IntProperty() if TYPE_CHECKING: cost_schedule: int diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 9315386008..c88ea4a00e 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator): ) bl_options = {"REGISTER"} - geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + geometry_library: bpy.props.EnumProperty( name="Geometry Library", description="Geometry library to use for testing shape creation.", items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)], # By default use the same library as used for importing ifc project. default="hybrid-cgal-simple-opencascade", ) - custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration] + custom_geometry_library: bpy.props.StringProperty( name="Custom Geometry Library", description="Provide a custom geometry library name, will override the 'geometry library' property.", ) @@ -781,7 +781,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Purge Unused Objects" bl_options = {"REGISTER", "UNDO"} - object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + object_type: bpy.props.EnumProperty( name="Object Type", items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) @@ -827,7 +827,7 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): ) bl_options = {"REGISTER", "UNDO"} - object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + object_type: bpy.props.EnumProperty( name="Object Type", items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) @@ -1073,7 +1073,7 @@ class ChangeLogLevel(bpy.types.Operator): bl_options = {"REGISTER"} bl_description = "Change general log level across all Python code in Blender" - log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + log_level: bpy.props.EnumProperty( name="Log Level", items=[(i, i, "") for i in get_args(LogLevelType)], default="WARNING", diff --git a/src/bonsai/bonsai/bim/module/drawing/__init__.py b/src/bonsai/bonsai/bim/module/drawing/__init__.py index a6a76439c1..7f24b8c2f5 100644 --- a/src/bonsai/bonsai/bim/module/drawing/__init__.py +++ b/src/bonsai/bonsai/bim/module/drawing/__init__.py @@ -108,6 +108,11 @@ classes = ( operator.ToggleTargetView, operator.OpenDocumentationWebUi, operator.FilterSelectedObjectsIfIntersectedByCamera, + operator.DrawParametricDimension, + operator.SetDimensionAnchor, + operator.RegenerateDimensions, + operator.ClickNearestDimensionAnchor, + operator.DebugDimensionClicks, prop.Variable, prop.Drawing, prop.Document, @@ -149,11 +154,17 @@ classes = ( gizmos.UglyDotGizmo, gizmos.ExtrusionGuidesGizmo, gizmos.ExtrusionWidget, + gizmos.GizmoAnchorHandle, + gizmos.DimensionAnchorWidget, + gizmos.DimensionLinePositionWidget, workspace.LaunchAnnotationTypeManager, workspace.Hotkey, ) +_keymaps = [] + + def menu_func(self, context): active_obj = context.active_object if active_obj: @@ -173,9 +184,17 @@ def register(): bpy.types.TextCurve.BIMTextProperties = bpy.props.PointerProperty(type=prop.BIMTextProperties) bpy.app.handlers.load_post.append(handler.load_post) bpy.app.handlers.depsgraph_update_pre.append(handler.depsgraph_update_pre_handler) + bpy.app.handlers.depsgraph_update_post.append(handler.depsgraph_update_post_handler) bpy.types.VIEW3D_MT_image_add.append(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.append(menu_func) + wm = bpy.context.window_manager + kc = wm.keyconfigs.addon + if kc: + km = kc.keymaps.new(name="3D View", space_type="VIEW_3D") + kmi = km.keymap_items.new("bim.click_nearest_dimension_anchor", "LEFTMOUSE", "PRESS") + _keymaps.append((km, kmi)) + def unregister(): if not bpy.app.background: @@ -188,5 +207,10 @@ def unregister(): del bpy.types.TextCurve.BIMTextProperties bpy.app.handlers.load_post.remove(handler.load_post) bpy.app.handlers.depsgraph_update_pre.remove(handler.depsgraph_update_pre_handler) + bpy.app.handlers.depsgraph_update_post.remove(handler.depsgraph_update_post_handler) + + for km, kmi in _keymaps: + km.keymap_items.remove(kmi) + _keymaps.clear() bpy.types.VIEW3D_MT_image_add.remove(ui.add_object_button) bpy.types.VIEW3D_MT_object_context_menu.remove(menu_func) diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index 9bc6e98a53..03bb402fee 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -807,19 +807,24 @@ class DecoratorData: pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {} show_description_only = pset_data.get("ShowDescriptionOnly", False) suppress_zero_inches = pset_data.get("SuppressZeroInches", False) + suppress_zero_feet = pset_data.get("SuppressZeroFeet", False) + is_ordinate = pset_data.get("IsOrdinate", False) text_prefix = pset_data.get("TextPrefix", None) or "" text_suffix = pset_data.get("TextSuffix", None) or "" - custom_unit_list = pset_data.get("CustomUnit", None) or "" - custom_unit = custom_unit_list[0] if custom_unit_list else "" + custom_units = list(pset_data.get("CustomUnit", None) or []) + separator = pset_data.get("Separator", None) or " / " return { "dimension_style": dimension_style, "show_description_only": show_description_only, "suppress_zero_inches": suppress_zero_inches, + "suppress_zero_feet": suppress_zero_feet, + "is_ordinate": is_ordinate, "text_prefix": text_prefix, "text_suffix": text_suffix, "fill_bg": fill_bg, - "custom_unit": custom_unit, + "custom_units": custom_units, + "separator": separator, } @classmethod diff --git a/src/bonsai/bonsai/bim/module/drawing/decoration.py b/src/bonsai/bonsai/bim/module/drawing/decoration.py index d3efe341a1..0e87ace977 100644 --- a/src/bonsai/bonsai/bim/module/drawing/decoration.py +++ b/src/bonsai/bonsai/bim/module/drawing/decoration.py @@ -490,7 +490,7 @@ class BaseDecorator: self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs) @cache - def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False): + def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False): drawing_pset_data = DrawingsData.data["active_drawing_pset_data"] precision = drawing_pset_data.get("MetricPrecision", None) if not precision: @@ -502,6 +502,7 @@ class BaseDecorator: precision=precision, decimal_places=decimal_places, suppress_zero_inches=suppress_zero_inches, + suppress_zero_feet=suppress_zero_feet, custom_unit=custom_unit, in_unit_length=in_unit_length, ) @@ -718,11 +719,13 @@ class DimensionDecorator(BaseDecorator): if not dimension_data: return show_description_only = dimension_data["show_description_only"] + is_ordinate = dimension_data["is_ordinate"] text_prefix = dimension_data["text_prefix"] text_suffix = dimension_data["text_suffix"] viewportDrawingScale = self.get_viewport_drawing_scale(context) text_offset_value = viewportDrawingScale * 3 + ordinate_total = 0.0 for i0, i1 in indices: v0 = Vector(vertices[i0]) v1 = Vector(vertices[i1]) @@ -741,16 +744,25 @@ class DimensionDecorator(BaseDecorator): "multiline": True, "text_dir": text_dir, } - base_pos = p0 + text_dir * 0.5 + base_pos = p1 if is_ordinate else p0 + text_dir * 0.5 if not show_description_only: - length = (v1 - v0).length - text = self.format_value( - context, - length, - suppress_zero_inches=dimension_data["suppress_zero_inches"], - custom_unit=dimension_data["custom_unit"], - ) + segment_length = (v1 - v0).length + if is_ordinate: + ordinate_total += segment_length + length = ordinate_total if is_ordinate else segment_length + units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None] + parts = [ + self.format_value( + context, + length, + suppress_zero_inches=dimension_data["suppress_zero_inches"], + suppress_zero_feet=dimension_data["suppress_zero_feet"], + custom_unit=unit, + ) + for unit in units_to_format + ] + text = dimension_data["separator"].join(str(p) for p in parts) if isinstance(self, DiameterDecorator): text = "D" + text text = text_prefix + text + text_suffix @@ -761,15 +773,18 @@ class DimensionDecorator(BaseDecorator): self.draw_label( text=text, - pos=base_pos + text_offset, - box_alignment="bottom-middle", + pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))), + box_alignment="bottom-right" if is_ordinate else "bottom-middle", multiline_to_bottom=False, **common_label_attrs, ) if not show_description_only and description: self.draw_label( - text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs + text=description, + pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))), + box_alignment="top-right" if is_ordinate else "top-middle", + **common_label_attrs, ) @@ -965,7 +980,9 @@ class RadiusDecorator(BaseDecorator): def get_text(): length = (spline_points[-1] - spline_points[-2]).length - return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"]) + units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None] + parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format] + return "R" + dimension_data["separator"].join(str(p) for p in parts) self.draw_dimension_text( context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center" @@ -2104,4 +2121,8 @@ class DecorationsHandler: object_decorators = DecoratorData.data.get("object_decorators", []) for obj, decorator in object_decorators: - decorator.decorate(context, obj) + try: + decorator.decorate(context, obj) + except ReferenceError: + DecoratorData.is_loaded = False + break diff --git a/src/bonsai/bonsai/bim/module/drawing/gizmos.py b/src/bonsai/bonsai/bim/module/drawing/gizmos.py index 7f3a53ddf4..a00ff0ce04 100644 --- a/src/bonsai/bonsai/bim/module/drawing/gizmos.py +++ b/src/bonsai/bonsai/bim/module/drawing/gizmos.py @@ -1285,7 +1285,7 @@ class SnapManager: continue coords = np.empty(vertex_count * 3, dtype=np.float32) - mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type] + mesh.vertices.foreach_get("co", coords) coords = coords.reshape(-1, 3) matrix = np.array(obj_eval.matrix_world, dtype=np.float32) @@ -1789,6 +1789,19 @@ DISC = ( (1.0, 0.0, 0), ) +# Anchor index currently being edited by SetDimensionAnchor (-1 = none). +_active_anchor_idx: int = -1 +# The annotation curve object being edited (kept so the gizmo group stays +# visible even when SetDimensionAnchor temporarily changes the active object). +_editing_annotation_obj = None + + +def set_active_anchor(idx: int, annotation_obj=None) -> None: + global _active_anchor_idx, _editing_annotation_obj + _active_anchor_idx = idx + _editing_annotation_obj = annotation_obj if idx >= 0 else None + + X3DISC = ( (0.0, 0.0, 0.0), (1.0, 0.0, 0), @@ -2120,6 +2133,340 @@ class ExtrusionWidget(types.GizmoGroup): self.handle.target_set_prop("offset", prop, "value") self.guides.target_set_prop("depth", prop, "value") + +class GizmoAnchorHandle(bpy.types.Gizmo): + """Visual-only dot at a parametric dimension vertex. + + No draw_select/invoke — any draw_select entry puts the gizmo in Blender's + select buffer, which causes the gizmo system to consume the click even + without an explicit invoke. All click handling is done by the + bim.click_nearest_dimension_anchor keymap operator. + """ + + bl_idname = "BIM_GT_anchor_handle" + + __slots__ = ("anchor_index", "custom_shape") + + def setup(self): + self.anchor_index = 0 + self.custom_shape = self.new_custom_shape(type="TRIS", verts=X3DISC) + + def draw(self, context): + self.draw_custom_shape(self.custom_shape) + + + +class DimensionAnchorWidget(types.GizmoGroup): + """Anchor handle gizmos at each vertex of the active parametric dimension. + + Green dots indicate vertices that are anchored to an IFC element face; + orange dots are free world-point anchors. Clicking any dot fires + ``bim.set_dimension_anchor`` pre-targeted at that vertex index. + """ + + bl_idname = "BIM_GGT_dimension_anchors" + bl_label = "Dimension Anchor Handles" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} + + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + _MAX_ANCHORS = 16 + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not tool.Ifc.get(): + return False + # Stay visible while SetDimensionAnchor is running (active obj may temporarily + # be an IFC element in the face-picking phase rather than the annotation). + if _active_anchor_idx >= 0 and _editing_annotation_obj is not None: + active = context.active_object + if active is _editing_annotation_obj: + return True # annotation still active + if active is not None and tool.Ifc.get_entity(active) is not None: + return True # face-picking phase: active obj is a target element + # Active object is None or a non-IFC object — the modal ended without + # calling set_active_anchor(-1). Reset stale state and fall through. + set_active_anchor(-1) + obj = context.active_object + if not obj or obj.type != "CURVE": + return False + if not obj.select_get(): + return False + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + return False + import ifcopenshell.util.element as _ue + if _ue.get_predefined_type(element) not in cls._DIM_TYPES: + return False + pset = _ue.get_pset(element, "BBIM_Dimension") + return bool(pset and pset.get("Anchors")) + + def setup(self, context: bpy.types.Context) -> None: + self._handles: list = [] + for _ in range(self._MAX_ANCHORS): + gz = self.gizmos.new("BIM_GT_anchor_handle") + gz.scale_basis = 0.2 + gz.use_draw_modal = True + gz.hide = True + self._handles.append(gz) + + def refresh(self, context: bpy.types.Context) -> None: + import json + import ifcopenshell.util.element as _ue + + obj = _editing_annotation_obj if _active_anchor_idx >= 0 and _editing_annotation_obj else context.active_object + if not obj or not obj.data or not getattr(obj.data, "splines", None): + for gz in self._handles: + gz.hide = True + return + + element = tool.Ifc.get_entity(obj) + if not element: + for gz in self._handles: + gz.hide = True + return + + pset = _ue.get_pset(element, "BBIM_Dimension") + if not pset or not pset.get("Anchors"): + for gz in self._handles: + gz.hide = True + return + + try: + anchors = json.loads(pset["Anchors"]) + except Exception: + for gz in self._handles: + gz.hide = True + return + + spline = obj.data.splines[0] + n = min(len(spline.points), len(anchors), self._MAX_ANCHORS) + + for i in range(n): + gz = self._handles[i] + raw_co = spline.points[i].co + world_co = obj.matrix_world @ raw_co.to_3d() + gz.matrix_basis = Matrix.Translation(world_co) + gz.anchor_index = i + if i == _active_anchor_idx and obj is _editing_annotation_obj: + gz.color = (0.2, 0.7, 1.0) + gz.color_highlight = (0.4, 0.85, 1.0) + elif anchors[i].get("guid"): + gz.color = (0.2, 0.85, 0.2) + gz.color_highlight = (0.4, 1.0, 0.4) + else: + gz.color = (0.9, 0.6, 0.1) + gz.color_highlight = (1.0, 0.85, 0.2) + gz.alpha = 0.85 + gz.alpha_highlight = 1.0 + gz.hide = False + + for i in range(n, self._MAX_ANCHORS): + self._handles[i].hide = True + + def draw_prepare(self, context: bpy.types.Context) -> None: + self.refresh(context) + + +class DimensionLinePositionWidget(types.GizmoGroup): + """Drag handle for the LinePosition of a parametric dimension annotation. + + Shows two opposing cones at the midpoint of the dimension curve, oriented + along the horizontal offset axis (cross(world_Z, dim_direction)). Dragging + either cone updates BBIM_Dimension.LinePosition and regenerates the curve in + real time. The forward cone points in +offset_dir; the reverse cone in + -offset_dir — both respond to mouse movement along the shared axis so the + user can drag in either direction from either handle. + """ + + bl_idname = "BIM_GGT_dimension_line_position" + bl_label = "Dimension Line Position" + bl_space_type = "VIEW_3D" + bl_region_type = "WINDOW" + bl_options = {"3D", "PERSISTENT", "SHOW_MODAL_ALL"} + + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + if not tool.Ifc.get(): + return False + obj = context.active_object + if not obj or obj.type != "CURVE": + return False + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + return False + import ifcopenshell.util.element as _ue + if _ue.get_predefined_type(element) not in cls._DIM_TYPES: + return False + pset = _ue.get_pset(element, "BBIM_Dimension") + return bool(pset and pset.get("Anchors") and pset.get("ForcePerpendicularToFace")) + + # ------------------------------------------------------------------ + # Helpers + + @staticmethod + def _offset_dir(obj: bpy.types.Object) -> "Vector | None": + """World-space unit direction perpendicular to the dimension line and world_Z.""" + if not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines: + return None + spline = obj.data.splines[0] + if len(spline.points) < 2: + return None + a = obj.matrix_world @ spline.points[0].co.to_3d() + b = obj.matrix_world @ spline.points[-1].co.to_3d() + dim = b - a + if dim.length < 1e-10: + return None + dim.normalize() + world_z = Vector((0.0, 0.0, 1.0)) + od = world_z.cross(dim) + if od.length < 1e-6: + od = Vector((1.0, 0.0, 0.0)).cross(dim) + if od.length < 1e-6: + return None + return od.normalized() + + @staticmethod + def _midpoint(obj: bpy.types.Object) -> "Vector": + spline = obj.data.splines[0] + pts = [obj.matrix_world @ p.co.to_3d() for p in spline.points] + return sum(pts, Vector()) / len(pts) + + @staticmethod + def _basis(origin: "Vector", x_axis: "Vector") -> "Matrix": + """4Ɨ4 matrix with translation=origin, local-X=x_axis.""" + ref = Vector((0.0, 0.0, 1.0)) if abs(x_axis.dot(Vector((0.0, 0.0, 1.0)))) < 0.9 else Vector((1.0, 0.0, 0.0)) + y_ax = x_axis.cross(ref).normalized() + z_ax = x_axis.cross(y_ax) + return Matrix([ + [x_axis.x, y_ax.x, z_ax.x, origin.x], + [x_axis.y, y_ax.y, z_ax.y, origin.y], + [x_axis.z, y_ax.z, z_ax.z, origin.z], + [0.0, 0.0, 0.0, 1.0], + ]) + + # ------------------------------------------------------------------ + # Value callbacks + + def _get_pos(self) -> float: + obj = bpy.context.active_object + if not obj: + return 0.0 + element = tool.Ifc.get_entity(obj) + if not element: + return 0.0 + import ifcopenshell.util.element as _ue + pset = _ue.get_pset(element, "BBIM_Dimension") + if not pset: + return 0.0 + stored = pset.get("LinePosition") + if stored is not None: + return float(stored) + # Natural position: projection of midpoint onto offset axis + od = self._offset_dir(obj) + if od is None: + return 0.0 + return self._midpoint(obj).dot(od) + + def _set_pos(self, value: float) -> None: + bpy.ops.ed.undo_push(message="Set Line Position") + import json + import numpy as np + import ifcopenshell.util.element as _ue + import ifcopenshell.api.pset as _pset_api + import ifcopenshell.api.drawing as drawing_api + from bonsai.bim.module.drawing.operator import _update_blender_curve + + obj = bpy.context.active_object + if not obj: + return + file = tool.Ifc.get() + if not file: + return + element = tool.Ifc.get_entity(obj) + if not element: + return + pset_data = _ue.get_pset(element, "BBIM_Dimension") + if not pset_data: + return + + pset_entity = file.by_id(pset_data["id"]) + _pset_api.edit_pset(file, pset=pset_entity, properties={"LinePosition": value}) + + anchors = json.loads(pset_data.get("Anchors") or "[]") + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) + if resolved_pts: + _update_blender_curve(element, resolved_pts) + tool.Blender.update_viewport() + + # ------------------------------------------------------------------ + # GizmoGroup interface + + def _make_cone(self, color: tuple, highlight: tuple) -> "bpy.types.Gizmo": + gz = self.gizmos.new("BIM_GT_gizmo_cone") + gz.color = color + gz.alpha = 0.8 + gz.color_highlight = highlight + gz.alpha_highlight = 1.0 + gz.scale_basis = 0.15 + gz.use_draw_modal = True + gz.prop_name = "Line Position" + gz.move_get_cb = self._get_pos + gz.move_set_cb = self._set_pos + gz.gizmo_group = self + gz.delta_scale = 1.0 + return gz + + def setup(self, context: bpy.types.Context) -> None: + color = (0.9, 0.6, 0.1) + highlight = (1.0, 0.9, 0.2) + self.gz_fwd = self._make_cone(color, highlight) + self.gz_rev = self._make_cone(color, highlight) + + def refresh(self, context: bpy.types.Context) -> None: + obj = context.active_object + if not obj: + self.gz_fwd.hide = self.gz_rev.hide = True + return + + od = self._offset_dir(obj) + if od is None: + self.gz_fwd.hide = self.gz_rev.hide = True + return + + mid = self._midpoint(obj) + # Lift each cone off the dimension line so the arrow base doesn't + # overlap anchor dots. 0.3 m gives clear separation at typical zoom. + _GAP = 0.15 + fwd_origin = mid + _GAP * od + rev_origin = mid - _GAP * od + + self.gz_fwd.matrix_basis = self._basis(fwd_origin, od) + self.gz_fwd.axis = od.copy() + self.gz_fwd.hide = False + + # Reverse cone: visually points in -od; same drag axis so both cones + # respond identically — drag toward either tip to move the line. + self.gz_rev.matrix_basis = self._basis(rev_origin, -od) + self.gz_rev.axis = od.copy() + self.gz_rev.hide = False + @staticmethod def get_scale_value(system: str, length_unit: str) -> float: scale_value = 1 diff --git a/src/bonsai/bonsai/bim/module/drawing/handler.py b/src/bonsai/bonsai/bim/module/drawing/handler.py index 026d274fb3..7f19905f31 100644 --- a/src/bonsai/bonsai/bim/module/drawing/handler.py +++ b/src/bonsai/bonsai/bim/module/drawing/handler.py @@ -16,15 +16,141 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +import json + import bpy +import numpy as np from bpy.app.handlers import persistent import bonsai.bim.module.drawing.decoration as decoration import bonsai.tool as tool +# --------------------------------------------------------------------------- +# Parametric dimension auto-regeneration state +# --------------------------------------------------------------------------- + +# Maps element GUID → list of annotation STEP IDs that reference it. +_dim_guid_index: dict = {} +# Persistent tessellation cache for the depsgraph handler (element id → shape). +_dim_shape_cache: dict = {} +# Set True whenever BBIM_Dimension anchors change or a new file loads. +_dim_index_dirty: bool = True +# Re-entry guard so curve updates don't trigger a second handler call. +_dim_handler_running: bool = False + + +def invalidate_dim_index() -> None: + """Mark the GUID index as stale so it is rebuilt on the next handler call.""" + global _dim_index_dirty, _dim_shape_cache + _dim_index_dirty = True + _dim_shape_cache.clear() + + +def _rebuild_dim_guid_index(file) -> None: + global _dim_guid_index, _dim_index_dirty + import ifcopenshell.util.element + + _dim_guid_index = {} + for annotation in file.by_type("IfcAnnotation"): + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data or not pset_data.get("Anchors"): + continue + try: + anchors = json.loads(pset_data["Anchors"]) + except Exception: + continue + ann_id = annotation.id() + for anchor in anchors: + guid = anchor.get("guid") + if not guid: + continue + ids = _dim_guid_index.setdefault(guid, []) + if ann_id not in ids: + ids.append(ann_id) + _dim_index_dirty = False + + +def regenerate_dims_for_layer(file, layer) -> None: + """Regenerate all parametric dimensions anchored to elements that use *layer*.""" + global _dim_shape_cache, _dim_index_dirty, _dim_guid_index + + if _dim_index_dirty: + _rebuild_dim_guid_index(file) + + affected_guids: set = set() + for layer_set in file.get_inverse(layer): + if not layer_set.is_a("IfcMaterialLayerSet"): + continue + for inv in file.get_inverse(layer_set): + if inv.is_a("IfcRelAssociatesMaterial"): + rels = [inv] + elif inv.is_a("IfcMaterialLayerSetUsage"): + rels = [r for r in file.get_inverse(inv) if r.is_a("IfcRelAssociatesMaterial")] + else: + continue + for rel in rels: + for element in rel.RelatedObjects: + if hasattr(element, "GlobalId"): + affected_guids.add(element.GlobalId) + _dim_shape_cache.pop(element.id(), None) + + if not affected_guids: + return + + annotation_ids: set = set() + for guid in affected_guids: + for ann_id in _dim_guid_index.get(guid, []): + annotation_ids.add(ann_id) + + if not annotation_ids: + return + + import ifcopenshell.util.element + import ifcopenshell.api.drawing as drawing_api + import ifcopenshell.geom + from bonsai.bim.module.drawing.operator import _update_blender_curve + + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("APPLY_DEFAULT_MATERIALS", False) + + for ann_id in annotation_ids: + try: + annotation = file.by_id(ann_id) + except Exception: + continue + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset: + continue + placement_override: dict = {} + try: + anchors_raw = json.loads(pset.get("Anchors") or "[]") + for anchor in anchors_raw: + guid = anchor.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + except Exception: + pass + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + settings=geom_settings, + shape_cache=_dim_shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + @persistent def load_post(*args): + invalidate_dim_index() props = tool.Drawing.get_document_props() if props.should_draw_decorations: decoration.DecorationsHandler.install(bpy.context) @@ -58,3 +184,177 @@ def set_active_camera_resolution(scene: bpy.types.Scene) -> None: raster_x, raster_y = props.update_camera_resolution() scene_render.resolution_x = raster_x scene_render.resolution_y = raster_y + + +def _sync_dimension_anchors_to_curve(file, annotation, obj) -> bool: + """Sync BBIM_Dimension.Anchors length to match the curve's spline point count. + + Called when the user adds or removes vertices from a dimension annotation in + Edit Mode. New vertices get a free WORLD-type anchor at their current world + position; removed tail vertices simply lose their anchor entries. + + Returns True if the pset was changed. + """ + import ifcopenshell.util.element + import ifcopenshell.api.pset + + if not obj.data or not getattr(obj.data, "splines", None) or not obj.data.splines: + return False + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data or not pset_data.get("Anchors"): + return False + + try: + anchors: list = json.loads(pset_data["Anchors"]) + except Exception: + return False + + spline = obj.data.splines[0] + spline_world = [obj.matrix_world @ p.co.to_3d() for p in spline.points] + n_pts = len(spline_world) + n_anchors = len(anchors) + + if n_pts == n_anchors: + return False + + # Match each spline point to the nearest unused anchor by proximity. + # This handles insertions (subdivide) and deletions correctly regardless + # of where in the polyline the edit happened. + _MATCH_THRESH_SQ = 1e-4 # 1 cm² — distinguishes existing pts from new midpoints + used: set = set() + new_anchors: list = [] + + for pt in spline_world: + best_idx, best_sq = None, float("inf") + for i, anc in enumerate(anchors): + if i in used: + continue + stored = anc.get("pt") + if not stored: + continue + dx, dy, dz = stored[0] - pt.x, stored[1] - pt.y, stored[2] - pt.z + sq = dx * dx + dy * dy + dz * dz + if sq < best_sq: + best_sq, best_idx = sq, i + if best_idx is not None and best_sq < _MATCH_THRESH_SQ: + new_anchors.append(anchors[best_idx]) + used.add(best_idx) + else: + new_anchors.append({ + "guid": None, + "type": "WORLD", + "addr": {}, + "hint": None, + "pt": [pt.x, pt.y, pt.z], + }) + + + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"Anchors": json.dumps(new_anchors)}) + invalidate_dim_index() + return True + + +@persistent +def depsgraph_update_post_handler(scene, depsgraph): + """Auto-regenerate parametric dimensions when referenced elements are moved.""" + global _dim_handler_running, _dim_index_dirty, _dim_guid_index, _dim_shape_cache + + if _dim_handler_running: + return + + file = tool.Ifc.get() + if not file: + return + + if _dim_index_dirty: + _rebuild_dim_guid_index(file) + + import ifcopenshell.util.element + + moved_guids: set = set() + edited_annotation_ids: set = set() + + for update in depsgraph.updates: + obj = update.id + if not isinstance(obj, bpy.types.Object): + continue + if not (update.is_updated_transform or update.is_updated_geometry): + continue + element = tool.Ifc.get_entity(obj) + if element is None or not hasattr(element, "GlobalId"): + continue + + + if update.is_updated_geometry and obj.type == "CURVE" and element.is_a("IfcAnnotation"): + import ifcopenshell.util.element as _ue + ptype = _ue.get_predefined_type(element) + if ptype in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"): + changed = _sync_dimension_anchors_to_curve(file, element, obj) + if changed: + edited_annotation_ids.add(element.id()) + continue + + moved_guids.add(element.GlobalId) + if update.is_updated_geometry: + _dim_shape_cache.pop(element.id(), None) + + annotation_ids: set = set(edited_annotation_ids) + for guid in moved_guids: + for ann_id in _dim_guid_index.get(guid, []): + annotation_ids.add(ann_id) + + if not annotation_ids: + return + + import ifcopenshell.api.drawing as drawing_api + import ifcopenshell.geom + from bonsai.bim.module.drawing.operator import _update_blender_curve + + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("APPLY_DEFAULT_MATERIALS", False) + + _dim_handler_running = True + try: + for ann_id in annotation_ids: + try: + annotation = file.by_id(ann_id) + except Exception: + continue + + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset: + continue + + placement_override: dict = {} + try: + anchors_raw = json.loads(pset.get("Anchors") or "[]") + for anchor in anchors_raw: + guid = anchor.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_id = elem.id() + if elem_id in placement_override: + continue + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem_id] = np.array(elem_obj.matrix_world) + except Exception: + pass + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + settings=geom_settings, + shape_cache=_dim_shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + finally: + _dim_handler_running = False diff --git a/src/bonsai/bonsai/bim/module/drawing/helper.py b/src/bonsai/bonsai/bim/module/drawing/helper.py index 7dce81359d..694b392319 100644 --- a/src/bonsai/bonsai/bim/module/drawing/helper.py +++ b/src/bonsai/bonsai/bim/module/drawing/helper.py @@ -170,6 +170,7 @@ def format_distance( precision=None, decimal_places=None, suppress_zero_inches=False, + suppress_zero_feet=False, in_unit_length=False, custom_unit=None, ): @@ -310,10 +311,10 @@ def format_distance( tx_dist = "" if feet: tx_dist += str(feet) + "'" - if not feet and not add_inches: + if not feet and not add_inches and not suppress_zero_feet: tx_dist += str(feet) + "'" - if not feet and add_inches: + if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet: if value < 0: tx_dist += "-0' - " else: @@ -456,7 +457,8 @@ def format_distance( tx_dist = fmt % d_cm else: - tx_dist = fmt % value + assert f"Unexpected unit_system - '{unit_system}'." + # tx_dist = fmt % value return tx_dist diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index b8ea0efb39..282ef50103 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -69,6 +69,8 @@ import bonsai.core.geometry import bonsai.tool as tool from bonsai.bim.helper import prop_with_search from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.model.polyline import PolylineOperator from bonsai.bim.module.drawing.data import DecoratorData, ElementValuesData from bonsai.bim.module.drawing.decoration import CutDecorator from bonsai.bim.module.drawing.prop import ( @@ -247,17 +249,17 @@ class CreateDrawing(bpy.types.Operator): + "Add the CTRL modifier to optionally open drawings to view them as\n" + "they are created" ) - print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + print_all: bpy.props.BoolProperty( name="Print All", default=False, options={"SKIP_SAVE"}, ) - open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + open_viewer: bpy.props.BoolProperty( name="Open in Viewer", default=False, options={"SKIP_SAVE"}, ) - sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + sync: bpy.props.BoolProperty( name="Sync Before Creating Drawing", description="Could save some time if you're sure IFC and current Blender session are already in sync", default=True, @@ -1427,6 +1429,7 @@ class CreateDrawing(bpy.types.Operator): "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus", + "Material.Name", ] group = root.find("{http://www.w3.org/2000/svg}g") @@ -2453,14 +2456,14 @@ class ActivateDrawingBase(tool.Ifc.Operator): + "SHIFT+CLICK to load a quick preview of the drawing view" ) - drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + drawing: bpy.props.IntProperty() + should_view_from_camera: bpy.props.BoolProperty( name="Should View From Camera", description="Move view to the activated drawing's camera position.", default=True, options={"SKIP_SAVE"}, ) - use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + use_quick_preview: bpy.props.BoolProperty( name="Use Quick Preview", description="Just move the camera to the drawing view, without loading anything else.", default=False, @@ -3766,14 +3769,12 @@ class ToggleTargetView(bpy.types.Operator): bl_label = "Toggle Target View" bl_options = {"REGISTER", "UNDO"} - target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] - toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + target_view: bpy.props.StringProperty() + toggle_all: bpy.props.BoolProperty( default=False, options={"SKIP_SAVE"}, ) - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] - items=[(i, i, "") for i in get_args(ToggleOption)] - ) + option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)]) if TYPE_CHECKING: target_view: str @@ -5473,3 +5474,2046 @@ class ShowElementValuesInstructions(bpy.types.Operator): def execute(self, context): return {"FINISHED"} + + +# --------------------------------------------------------------------------- +# Parametric dimension operators +# --------------------------------------------------------------------------- + + +class DrawParametricDimension(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): + """Draw a parametric dimension string anchored to IFC element geometry. + + Click on IFC element faces to place dimension vertices one by one using the + same snap system as wall and slab drawing. Each confirmed point is stored + as a parametric anchor in ``BBIM_Dimension`` so the dimension + recomputes automatically when the referenced elements move. + + RMB or ENTER to finish; ESC to cancel without creating an annotation. + """ + + bl_idname = "bim.draw_parametric_dimension" + bl_label = "Draw Parametric Dimension" + bl_options = {"REGISTER", "UNDO"} + + _SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX") + + if TYPE_CHECKING: + _anchors: list + _shape_cache: dict + _snap_mode: str + _ifc_snap_candidate: object # Optional[dict] + _draw_handler: object # SpaceView3D draw handler handle + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC file loaded.") + return False + if not context.scene.camera or not tool.Ifc.get_entity(context.scene.camera): + cls.poll_message_set("No active drawing.") + return False + return context.space_data.type == "VIEW_3D" + + def __init__(self, *args, **kwargs): + bpy.types.Operator.__init__(self, *args, **kwargs) + PolylineOperator.__init__(self) + self._anchors = [] + self._shape_cache = {} + self._force_perpendicular = False + self._anchor0_normal = None # (nx, ny, nz) world-space face normal of anchor[0] + self._anchor0_pt = None # (x, y, z) world-space position of anchor[0] + self._snap_mode = "FACE" + self._ifc_snap_candidate = None + self._draw_handler = None + self._snap_cand_obj_ptr: int = -1 # Blender object pointer for cached snap cands + self._snap_cand_cache: list = [] # cached get_layer/profile_snap_candidates result + self._snap_cand_multi_cache: dict = {} # ptr → candidates for coplanar-edge nearby objects + + # ------------------------------------------------------------------ + # Snap → anchor bridge + + def _snap_to_anchor(self, snap: dict) -> dict: + """Convert a PolylineOperator snap candidate to a BBIM_Dimension anchor dict.""" + import ifcopenshell.api.drawing as drawing_api + + obj = snap.get("object") + element = tool.Ifc.get_entity(obj) if obj else None + pt_world = snap["point"] + + if element and hasattr(element, "GlobalId") and obj.data and hasattr(obj.data, "polygons"): + hit_m = (float(pt_world.x), float(pt_world.y), float(pt_world.z)) + + face_index = snap.get("face_index") + if face_index is None or face_index >= len(obj.data.polygons): + # Vertex / edge snap: seed from closest face. + local_pt = obj.matrix_world.inverted() @ pt_world + ok, _loc, _n, face_index = obj.closest_point_on_mesh(local_pt) + if not ok: + face_index = None + + # Prefer faces perpendicular to the camera rather than faces that + # directly face the camera (e.g. top of a wall in plan view). + face_index = _prefer_perp_face_index(obj, pt_world, face_index) + + if face_index is not None and face_index < len(obj.data.polygons): + normal_local = obj.data.polygons[face_index].normal + else: + normal_local = Vector((0.0, 0.0, 1.0)) + + normal_world = (obj.matrix_world.to_3x3() @ normal_local).normalized() + normal_m = (float(normal_world.x), float(normal_world.y), float(normal_world.z)) + placement_override = {element.id(): np.array(obj.matrix_world)} + + return drawing_api.build_anchor_from_hit( + tool.Ifc.get(), element, hit_m, normal_m, + shape_cache=self._shape_cache, + placement_override=placement_override, + ) + + # Axis / plane snap, or non-IFC object: store a free world point. + import ifcopenshell.api.drawing as drawing_api + return drawing_api.make_world_anchor([float(pt_world.x), float(pt_world.y), float(pt_world.z)]) + + # ------------------------------------------------------------------ + # Point insertion — capture anchor in sync with polyline point + + def handle_inserting_polyline(self, context, event): + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + count_before = len(polyline_data[0].polyline_points) if polyline_data else 0 + + # Capture snap state BEFORE super() so we have it even if event processing clears it. + snap = self.snapping_points[0] if self.snapping_points else None + is_mouse_click = ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type == "LEFTMOUSE" + ) + + super().handle_inserting_polyline(context, event) + + if not polyline_data: + return + count_after = len(polyline_data[0].polyline_points) + + if count_after > count_before: + # A point was inserted — build its anchor. + if is_mouse_click and self._ifc_snap_candidate: + self._anchors.append(self._build_ifc_anchor(self._ifc_snap_candidate)) + elif is_mouse_click and snap and snap.get("type") not in {"Axis", "Plane"}: + self._anchors.append(self._snap_to_anchor(snap)) + else: + # Keyboard-typed coordinate or close-loop: world anchor at the stored point. + import ifcopenshell.api.drawing as drawing_api + pt = polyline_data[0].polyline_points[-1] + self._anchors.append(drawing_api.make_world_anchor([float(pt.x), float(pt.y), float(pt.z)])) + + # After anchor[0] is set, extract its face normal for the perp constraint. + if self._force_perpendicular and len(self._anchors) == 1: + self._update_perp_constraint() + elif count_after < count_before and self._anchors: + # BACKSPACE removed a point. + self._anchors.pop() + # Reset constraint if we backspaced past anchor[0]. + if len(self._anchors) == 0: + self._anchor0_normal = None + self._anchor0_pt = None + + # ------------------------------------------------------------------ + # Perpendicular-to-face constraint helpers + + def _update_perp_constraint(self) -> None: + """Extract the face normal from anchor[0] and store it as the constraint axis.""" + import math + from mathutils import Vector + a = self._anchors[0] if self._anchors else None + if not a or a.get("type") != "FACE": + return + addr = a.get("addr") or {} + pt = a.get("pt") + if not pt: + return + + method = addr.get("method", "FACE_NORMAL") + guid = a.get("guid") + + if method == "LAYER_BOUNDARY": + # Derive the thickness-axis normal from the element's LayerSetDirection. + if not guid: + return + try: + file = tool.Ifc.get() + element = file.by_guid(guid) + import ifcopenshell.util.element as _ifc_elem + usage = _ifc_elem.get_material(element, should_inherit=True) + if not usage or not usage.is_a("IfcMaterialLayerSetUsage"): + return + axis = getattr(usage, "LayerSetDirection", None) or "AXIS2" + if axis == "AXIS1": + normal_local = (1.0, 0.0, 0.0) + elif axis == "AXIS3": + normal_local = (0.0, 0.0, 1.0) + else: + normal_local = (0.0, 1.0, 0.0) + obj = tool.Ifc.get_object(element) + if obj: + nw = obj.matrix_world.to_3x3() @ Vector(normal_local) + nw.normalize() + n: tuple = (nw.x, nw.y, nw.z) + else: + n = normal_local + except Exception: + return + else: + normal_local = addr.get("normal_local") + if not normal_local: + return + n = normal_local + if guid: + try: + file = tool.Ifc.get() + element = file.by_guid(guid) + obj = tool.Ifc.get_object(element) + if obj: + nw = obj.matrix_world.to_3x3() @ Vector(normal_local) + nw.normalize() + n = (nw.x, nw.y, nw.z) + except Exception: + pass + + mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) + if mag < 1e-12: + return + self._anchor0_normal = (n[0] / mag, n[1] / mag, n[2] / mag) + self._anchor0_pt = tuple(pt) + + def _apply_perp_constraint(self) -> None: + """Project the current snap point onto the constraint line when active.""" + if not self._force_perpendicular or not self._anchor0_normal or not self._anchor0_pt: + return + if not self._anchors: # constraint not yet active (no anchor[0] yet) + return + if not self.snapping_points: + return + snap = self.snapping_points[0] + if not snap or not snap.get("point"): + return + p = snap["point"] + base = self._anchor0_pt + n = self._anchor0_normal + t = (p.x - base[0]) * n[0] + (p.y - base[1]) * n[1] + (p.z - base[2]) * n[2] + constrained = Vector((base[0] + t * n[0], base[1] + t * n[1], base[2] + t * n[2])) + snap["point"] = constrained + + # ------------------------------------------------------------------ + # IFC-native snap for LAYER / VERTEX / EDGE modes + + @staticmethod + def _snap_on_coplanar_faces(obj, hit_pt_world, tol_z=1e-3): + """Return FACE snap candidates for vertical mesh faces with an edge at hit_pt_world's Z. + + Finds faces that are edge-on to the camera (perpendicular to the floor plane) and + whose bottom edge is coplanar with the hovered floor surface, then projects the + hit point onto each such face's plane to get the snap position. + """ + from mathutils import Vector + mx = obj.matrix_world + mesh = obj.data + target_z = float(hit_pt_world.z) + hit_pt = Vector(hit_pt_world) + candidates = [] + for poly in mesh.polygons: + normal_w = (mx.to_3x3() @ poly.normal).normalized() + if abs(normal_w.z) > 0.9: + continue + verts_w = [mx @ mesh.vertices[vi].co for vi in poly.vertices] + n = len(verts_w) + has_coplanar_edge = any( + abs(verts_w[i].z - target_z) <= tol_z and abs(verts_w[(i + 1) % n].z - target_z) <= tol_z + for i in range(n) + ) + if not has_coplanar_edge: + continue + face_center_w = sum(verts_w, Vector((0.0, 0.0, 0.0))) / n + dist = (hit_pt - face_center_w).dot(normal_w) + snapped_pt = hit_pt - normal_w * dist + candidates.append({ + "type": "FACE", + "snap_world": (snapped_pt.x, snapped_pt.y, snapped_pt.z), + "snap": "FACE", + "face_verts": [tuple(v) for v in verts_w], + }) + return candidates + + @staticmethod + def _pt_in_obj_bbox(obj, pt_world, tol=1e-3): + """Return True if pt_world is inside obj's world-space bounding box.""" + try: + local_pt = obj.matrix_world.inverted() @ pt_world + except Exception: + return False + bb = obj.bound_box # 8 corners in local space + xs = [v[0] for v in bb] + ys = [v[1] for v in bb] + zs = [v[2] for v in bb] + return ( + min(xs) - tol <= local_pt.x <= max(xs) + tol + and min(ys) - tol <= local_pt.y <= max(ys) + tol + and min(zs) - tol <= local_pt.z <= max(zs) + tol + ) + + def _compute_ifc_snap_candidate(self, context, event) -> "Optional[dict]": + """Return an IFC-native snap candidate for the current snap mode, or None.""" + if not self.snapping_points: + return None + + snap = self.snapping_points[0] + hit_obj = snap.get("object") + hit_pt = snap.get("point") + + from bpy_extras.view3d_utils import location_3d_to_region_2d + import ifcopenshell.api.drawing as drawing_api + + region = context.region + rv3d = context.region_data + mx, my = event.mouse_region_x, event.mouse_region_y + + # ---------------------------------------------------------------- + # FACE mode: no IFC snap on the primary hit object, but look for + # coplanar-edge candidates from nearby objects (e.g. a wall whose + # bottom edge is coplanar with the hovered floor surface). + # Uses direct mesh edge projection rather than get_profile_snap_candidates + # so the snap tracks cursor position along the edge, not just fixed vertices. + # ---------------------------------------------------------------- + if self._snap_mode == "FACE": + if hit_pt is None or not self.objs_2d_bbox: + return None + nearby_cands = [] + extra_count = 0 + for obj, _bbox2d in self.objs_2d_bbox: + if extra_count >= 4: + break + if obj is hit_obj: + continue + if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): + continue + extra_elem = tool.Ifc.get_entity(obj) + if not extra_elem or not hasattr(extra_elem, "GlobalId"): + continue + in_bbox = self._pt_in_obj_bbox(obj, hit_pt) + if not in_bbox: + continue + face_cands = self._snap_on_coplanar_faces(obj, hit_pt) + nearby_cands.extend((c, extra_elem, obj) for c in face_cands) + extra_count += 1 + + _FACE_THRESH_D2 = 30 * 30 + best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") + for cand, elem, obj in nearby_cands: + sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"])) + if sp is None: + continue + d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2 + if d2 < best_d2 and d2 < _FACE_THRESH_D2: + best_d2 = d2 + best_cand = cand + best_elem = elem + best_obj = obj + if best_cand is None: + return None + result = dict(best_cand) + result["element"] = best_elem + result["obj"] = best_obj + return result + + # ---------------------------------------------------------------- + # LAYER / VERTEX / EDGE modes + # ---------------------------------------------------------------- + if not hit_obj: + return None + element = tool.Ifc.get_entity(hit_obj) + if not element or not hasattr(element, "GlobalId"): + return None + + # Recompute expensive candidate geometry only when the hovered object changes. + obj_ptr = hit_obj.as_pointer() + if obj_ptr != self._snap_cand_obj_ptr: + file = tool.Ifc.get() + placement_override = {element.id(): np.array(hit_obj.matrix_world)} + if self._snap_mode == "LAYER": + self._snap_cand_cache = drawing_api.get_layer_snap_candidates(file, element, placement_override) + else: + self._snap_cand_cache = drawing_api.get_profile_snap_candidates(file, element, placement_override) + self._snap_cand_obj_ptr = obj_ptr + + if self._snap_mode == "LAYER": + all_layer_cands = [(c, element, hit_obj) for c in self._snap_cand_cache] + if hit_pt is not None and self.objs_2d_bbox: + file = tool.Ifc.get() + extra_count = 0 + for obj, _bbox2d in self.objs_2d_bbox: + if extra_count >= 4: + break + if obj is hit_obj: + continue + if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): + continue + extra_elem = tool.Ifc.get_entity(obj) + if not extra_elem or not hasattr(extra_elem, "GlobalId"): + continue + if not self._pt_in_obj_bbox(obj, hit_pt): + continue + extra_ptr = obj.as_pointer() + if extra_ptr not in self._snap_cand_multi_cache: + placement_override = {extra_elem.id(): np.array(obj.matrix_world)} + self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_layer_snap_candidates( + file, extra_elem, placement_override + ) + all_layer_cands.extend((c, extra_elem, obj) for c in self._snap_cand_multi_cache[extra_ptr]) + extra_count += 1 + best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") + for cand, elem, obj in all_layer_cands: + sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"])) + if sp is None: + continue + d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2 + if d2 < best_d2: + best_d2 = d2 + best_cand = cand + best_elem = elem + best_obj = obj + if best_cand is None: + return None + result = dict(best_cand) + result["method"] = "LAYER_BOUNDARY" + result["element"] = best_elem + result["obj"] = best_obj + return result + + # VERTEX or EDGE — profile-based candidates. + # Build a tagged list of (candidate, element, obj) so the best match from + # any object carries the right element reference into _build_ifc_anchor. + all_cands = [(c, element, hit_obj) for c in self._snap_cand_cache] + + # Also query nearby objects whose 3D bbox contains the hit point. + if hit_pt is not None and self.objs_2d_bbox: + file = tool.Ifc.get() + extra_count = 0 + for obj, _bbox2d in self.objs_2d_bbox: + if extra_count >= 4: + break + if obj is hit_obj: + continue + if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): + continue + extra_elem = tool.Ifc.get_entity(obj) + if not extra_elem or not hasattr(extra_elem, "GlobalId"): + continue + in_bbox = self._pt_in_obj_bbox(obj, hit_pt) + if not in_bbox: + continue + extra_ptr = obj.as_pointer() + if extra_ptr not in self._snap_cand_multi_cache: + placement_override = {extra_elem.id(): np.array(obj.matrix_world)} + self._snap_cand_multi_cache[extra_ptr] = drawing_api.get_profile_snap_candidates( + file, extra_elem, placement_override + ) + all_cands.extend((c, extra_elem, obj) for c in self._snap_cand_multi_cache[extra_ptr]) + extra_count += 1 + + best_cand, best_elem, best_obj, best_d2 = None, None, None, float("inf") + for cand, elem, obj in all_cands: + if cand["type"] != self._snap_mode: + continue + sp = location_3d_to_region_2d(region, rv3d, Vector(cand["snap_world"])) + if sp is None: + continue + d2 = (sp.x - mx) ** 2 + (sp.y - my) ** 2 + if d2 < best_d2: + best_d2 = d2 + best_cand = cand + best_elem = elem + best_obj = obj + if best_cand is None: + return None + result = dict(best_cand) + result["element"] = best_elem + result["obj"] = best_obj + return result + + def _build_ifc_anchor(self, candidate: dict) -> dict: + """Build the correct anchor type from an IFC snap candidate.""" + import ifcopenshell.api.drawing as drawing_api + element = candidate.get("element") + if not element: + pt = candidate.get("snap_world", (0.0, 0.0, 0.0)) + return drawing_api.make_world_anchor(list(pt)) + file = tool.Ifc.get() + if candidate.get("method") == "LAYER_BOUNDARY": + return drawing_api.build_anchor_from_layer_boundary(file, element, candidate) + snap_kind = candidate.get("snap") + if snap_kind == "VERTEX" and candidate.get("profile_x_m") is not None: + return drawing_api.build_anchor_from_profile_vert(file, element, candidate) + if snap_kind == "EDGE" and candidate.get("profile_x_m") is not None: + return drawing_api.build_anchor_from_profile_edge(file, element, candidate) + pt = candidate.get("snap_world", (0.0, 0.0, 0.0)) + return drawing_api.make_world_anchor(list(pt)) + + def _update_snap_draw_data(self) -> None: + """Populate _snap_draw_data so _draw_snap_indicator_global draws the right indicator.""" + _snap_draw_data.clear() + + if self._ifc_snap_candidate: + cand = self._ifc_snap_candidate + if cand.get("method") == "LAYER_BOUNDARY": + _snap_draw_data.update({ + "type": "LAYER", + "seam_corners": cand.get("seam_corners", []), + "snap_world": cand.get("snap_world"), + }) + elif cand.get("snap") == "FACE": + _snap_draw_data.update({ + "type": "FACE", + "face_verts": cand.get("face_verts", []), + "snap_world": cand.get("snap_world"), + }) + elif cand.get("snap") == "EDGE": + _snap_draw_data.update({ + "type": "EDGE", + "v0": cand.get("v0"), + "v1": cand.get("v1"), + "snap_world": cand.get("snap_world"), + }) + else: + _snap_draw_data.update({ + "type": "VERTEX", + "snap_world": cand.get("snap_world"), + }) + return + + # FACE mode: outline the hovered face polygon + if not self.snapping_points: + return + snap = self.snapping_points[0] + hit_obj = snap.get("object") + if not hit_obj or not hasattr(hit_obj.data, "polygons"): + return + pt_world = snap.get("point") + face_index = snap.get("face_index") + if face_index is None or face_index >= len(hit_obj.data.polygons): + if pt_world is not None: + local_pt = hit_obj.matrix_world.inverted() @ pt_world + try: + ok, _loc, _n, face_index = hit_obj.closest_point_on_mesh(local_pt) + except RuntimeError: + return + if not ok: + return + face_index = _prefer_perp_face_index(hit_obj, pt_world, face_index) + if face_index is None or face_index >= len(hit_obj.data.polygons): + return + face = hit_obj.data.polygons[face_index] + mx = hit_obj.matrix_world + face_verts = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices] + _snap_draw_data.update({"type": "FACE", "face_verts": face_verts}) + + def _cleanup(self, context) -> None: + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + context.workspace.status_text_set(None) + + def _set_status(self, context) -> None: + mode_label = self._snap_mode.capitalize() + context.workspace.status_text_set( + f"[Snap: {mode_label}] TAB: cycle snap | LMB: place point" + " | 0-9: enter value | BACKSPACE: undo last | RMB/ENTER: finish | ESC: cancel" + ) + + # ------------------------------------------------------------------ + # Finalize: create IfcAnnotation + BBIM_Dimension pset + + def _create_dimension_from_polyline(self, context) -> None: + import ifcopenshell.api.drawing as drawing_api + + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + if not polyline_data or len(polyline_data[0].polyline_points) < 2: + self.report({"WARNING"}, "Need at least 2 points for a dimension.") + return + + polyline_points = list(polyline_data[0].polyline_points) + + dprops = tool.Drawing.get_document_props() + drawing = dprops.get_active_drawing() + if not drawing: + self.report({"WARNING"}, "No active drawing.") + return + + props = tool.Drawing.get_annotation_props() + relating_type = ( + tool.Ifc.get().by_id(int(props.relating_type_id)) + if props.relating_type_id and props.relating_type_id != "0" + else None + ) + + obj = core.add_annotation( + tool.Ifc, tool.Collector, tool.Drawing, + drawing=drawing, + object_type="DIMENSION", + relating_type=relating_type, + enable_editing=False, + ) + if not obj: + return + + annotation = tool.Ifc.get_entity(obj) + if not annotation: + return + + # World-space metres points straight from the polyline. + resolved_pts_m = [(pt.x, pt.y, pt.z) for pt in polyline_points] + + # Update Blender curve spline AND the IFC IfcIndexedPolyCurve/IfcPolyline. + _update_blender_curve(annotation, resolved_pts_m) + + # Pad anchors to match point count (e.g. if first point was keyboard-typed + # before any snap data was available). + anchors = list(self._anchors) + while len(anchors) < len(resolved_pts_m): + anchors.append(drawing_api.make_world_anchor(list(resolved_pts_m[len(anchors)]))) + + file = tool.Ifc.get() + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data: + ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + pset_entity = file.by_id(pset_data["id"]) + pset_props = {"Anchors": json.dumps(anchors)} + if self._force_perpendicular: + pset_props["ForcePerpendicularToFace"] = True + ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties=pset_props) + + if self._force_perpendicular: + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + resolved_pts = drawing_api.regenerate_dimension( + file, annotation, + shape_cache=getattr(self, "_shape_cache", None), + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + + from bonsai.bim.module.drawing import handler as _drawing_handler + _drawing_handler.invalidate_dim_index() + + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + + # ------------------------------------------------------------------ + # Modal loop — same pattern as DrawPolylineWall + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _modal(self, context, event): + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + self.handle_lock_axis(context, event) + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + self.handle_mouse_move(context, event) + self.choose_axis(event) + self.handle_snap_selection(context, event) + self._apply_perp_constraint() + + # TAB: cycle snap mode when not in keyboard-input mode. + # Consume both PRESS and RELEASE so the RELEASE never reaches + # handle_keyboard_input (which would activate input mode on TAB RELEASE). + # When is_input_on is True, TAB passes through to cycle input fields as usual. + if event.type == "TAB" and not self.tool_state.is_input_on: + if event.value == "PRESS": + cur = self._SNAP_MODES.index(self._snap_mode) + self._snap_mode = self._SNAP_MODES[(cur + 1) % len(self._SNAP_MODES)] + self._ifc_snap_candidate = None + self._snap_cand_obj_ptr = -1 # invalidate cache: LAYER vs VERTEX/EDGE differ + self._snap_cand_multi_cache = {} # invalidate nearby-object cache too + self._set_status(context) + return {"RUNNING_MODAL"} + + # For LAYER / VERTEX / EDGE modes, compute an IFC-native snap candidate and + # override the polyline cursor position so the visual tracks the IFC point. + # Only recompute on mouse moves — key events don't change the hit object. + if event.type == "MOUSEMOVE": + self._ifc_snap_candidate = self._compute_ifc_snap_candidate(context, event) + if self._ifc_snap_candidate and self.snapping_points: + wp = self._ifc_snap_candidate.get("snap_world") + if wp: + self.snapping_points[0]["point"] = Vector(wp) + + self._update_snap_draw_data() + + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + self._create_dimension_from_polyline(context) + self.tool_state.plane_method = None + PolylineDecorator.uninstall() + tool.Polyline.clear_polyline() + self._cleanup(context) + tool.Blender.update_viewport() + return {"FINISHED"} + + self.handle_keyboard_input(context, event) + self.handle_inserting_polyline(context, event) + + cancel = self.handle_cancelation(context, event) + if cancel is not None: + self._cleanup(context) + return cancel + + return {"RUNNING_MODAL"} + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def _init_snapping_points(self, context, event): + """Skip the full BVH snap at startup — use a plane-intersection placeholder. + + handle_mouse_move populates snapping_points properly after the first few + MOUSEMOVE events, so this placeholder only needs to survive until then. + We must also populate snap_mouse_point (a Blender prop collection) because + calculate_distance_and_angle accesses it immediately after invoke. + """ + from mathutils import Vector + plane_pt = tool.Raycast.ray_cast_to_plane(context, event, Vector((0, 0, 0)), Vector((0, 0, 1))) + snap = {"type": "Plane", "point": plane_pt, "object": None, "group": "Plane", "distance": 10} + self.snapping_points = [snap] + tool.Snap.update_snapping_point(plane_pt, "Plane") + + def _invoke(self, context, event): + super().invoke(context, event) + self._force_perpendicular = tool.Drawing.get_annotation_props().force_perpendicular_to_face + _snap_draw_data.clear() + self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( + _draw_snap_indicator_global, (), "WINDOW", "POST_VIEW" + ) + self._set_status(context) + return {"RUNNING_MODAL"} + + +def _prefer_perp_face_index( + obj: "bpy.types.Object", + hit_world: "Vector", + current_index: "Optional[int]", + world_matrix=None, +) -> "Optional[int]": + """Return the polygon index most perpendicular to the camera near *hit_world*. + + If the camera is unavailable or the current face is already sufficiently + perpendicular (|dot| < 0.5), returns *current_index* unchanged. + *world_matrix* overrides ``obj.matrix_world``; useful when *obj* is a mesh + inside a collection instance whose effective transform differs from its own + ``matrix_world``. + """ + camera = bpy.context.scene.camera + if not camera or not obj.data or not hasattr(obj.data, "polygons"): + return current_index + + cam_view = (camera.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))).normalized() + mx = world_matrix if world_matrix is not None else obj.matrix_world + mx3 = mx.to_3x3() + + if current_index is not None and current_index < len(obj.data.polygons): + current_n = (mx3 @ obj.data.polygons[current_index].normal).normalized() + if abs(current_n.dot(cam_view)) < 0.5: + return current_index + + best_idx = current_index + best_score = -1.0 + for i, poly in enumerate(obj.data.polygons): + n_world = (mx3 @ poly.normal).normalized() + perp = 1.0 - abs(n_world.dot(cam_view)) + if perp < 0.5: + continue + dist = (mx @ poly.center - hit_world).length + score = perp - dist / 4.0 + if score > best_score: + best_score = score + best_idx = i + return best_idx + + +# Module-level draw data so the GPU callback never touches the operator RNA struct. +_snap_draw_data: dict = {} + + + +def _draw_snap_indicator_global(): + """GPU draw callback (POST_VIEW) — draws face outline, edge, or vertex dot.""" + data = _snap_draw_data + if not data or not data.get("type"): + return + import gpu + from gpu_extras.batch import batch_for_shader + try: + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("ALWAYS") + snap_type = data["type"] + + if snap_type == "FACE": + verts = data.get("face_verts", []) + if len(verts) >= 3: + lines = [] + for i in range(len(verts)): + lines.append(verts[i]) + lines.append(verts[(i + 1) % len(verts)]) + shader.bind() + shader.uniform_float("color", (0.2, 0.55, 1.0, 0.9)) + gpu.state.line_width_set(4.0) + batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader) + + elif snap_type == "EDGE": + v0, v1 = data.get("v0"), data.get("v1") + if v0 and v1: + shader.bind() + shader.uniform_float("color", (1.0, 0.65, 0.0, 1.0)) + gpu.state.line_width_set(6.0) + batch_for_shader(shader, "LINES", {"pos": [v0, v1]}).draw(shader) + gpu.state.point_size_set(12.0) + batch_for_shader(shader, "POINTS", {"pos": [v0, v1]}).draw(shader) + + elif snap_type == "VERTEX": + pt = data.get("snap_world") + if pt: + shader.bind() + shader.uniform_float("color", (1.0, 0.2, 0.4, 1.0)) + gpu.state.point_size_set(20.0) + batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader) + + elif snap_type == "LAYER": + corners = data.get("seam_corners", []) + pt = data.get("snap_world") + shader.bind() + shader.uniform_float("color", (0.2, 0.9, 0.5, 1.0)) + n = len(corners) + if n >= 2: + lines = [] + for i in range(n): + lines.append(corners[i]) + lines.append(corners[(i + 1) % n]) + gpu.state.line_width_set(5.0) + batch_for_shader(shader, "LINES", {"pos": lines}).draw(shader) + gpu.state.point_size_set(10.0) + batch_for_shader(shader, "POINTS", {"pos": corners}).draw(shader) + if pt: + gpu.state.point_size_set(20.0) + batch_for_shader(shader, "POINTS", {"pos": [pt]}).draw(shader) + + except Exception: + pass + finally: + try: + gpu.state.depth_test_set("LESS_EQUAL") + gpu.state.blend_set("NONE") + gpu.state.line_width_set(1.0) + except Exception: + pass + + +class SetDimensionAnchor(bpy.types.Operator, tool.Ifc.Operator): + """Interactively anchor dimension vertices to IFC element faces. + + Two-phase modal workflow (all in Object Mode): + 1. Run the operator with a dimension annotation selected. + 2. Click a vertex ON the dimension line to select it. + 3. Hover over IFC elements — the nearest candidate is highlighted. + TAB cycles through overlapping candidates under the cursor. + Click to anchor the highlighted element face to that vertex. + ALT+Click sets a free world-point anchor instead. + 4. Repeat steps 2-3 for more vertices. + 5. RMB or ESC to finish. + """ + + bl_idname = "bim.set_dimension_anchor" + bl_label = "Set Dimension Anchor" + bl_options = {"REGISTER", "UNDO"} + + anchor_index: bpy.props.IntProperty(default=-1) # -1 = begin with vertex-pick phase + + if TYPE_CHECKING: + anchor_index: int + + _annotation: Optional[ifcopenshell.entity_instance] = None + _annotation_obj: Optional[bpy.types.Object] = None + _phase: str = "PICK_VERTEX" # "PICK_VERTEX" | "PICK_FACE" + _active_vertex_idx: int = -1 + _shape_cache: dict + _region: Optional[bpy.types.Region] = None + _rv3d: Optional[bpy.types.RegionView3D] = None + + # Hover-cycle state (active during PICK_FACE phase) + _hover_candidates: list # [(ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index), ...] + _hover_index: int # which element candidate is currently highlighted + _hover_last_px: tuple # last cursor pixel position where candidates were computed + _hover_highlighted_obj: Optional[bpy.types.Object] # object currently selected for highlight + + # Snap-mode cycle state (FACE → EDGE → VERTEX, TAB) + _snap_mode: str # "FACE" | "EDGE" | "VERTEX" + _draw_handler: object # SpaceView3D draw handler handle + + _VERTEX_PICK_RADIUS_PX = 20 + _HOVER_THROTTLE_PX_SQ = 144 # 12 px — enough to feel responsive without per-pixel recompute + _SNAP_MODES = ("FACE", "LAYER", "EDGE", "VERTEX") + + @classmethod + def poll(cls, context): + if not tool.Ifc.get(): + cls.poll_message_set("No IFC file loaded.") + return False + obj = context.active_object + if not obj: + cls.poll_message_set("No active object.") + return False + if context.mode != "OBJECT": + cls.poll_message_set("Must be in Object Mode.") + return False + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + cls.poll_message_set("Active object must be an IfcAnnotation.") + return False + ptype = ifcopenshell.util.element.get_predefined_type(element) + if ptype not in ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"): + cls.poll_message_set("Annotation must be a dimension type.") + return False + return True + + def invoke(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="INVOKE") + + def modal(self, context, event): + return IfcStore.execute_ifc_operator(self, context, event, method="MODAL") + + def _invoke(self, context, event): + obj = context.active_object + self._annotation = tool.Ifc.get_entity(obj) + self._annotation_obj = obj + self._shape_cache = {} + if self.anchor_index >= 0: + self._phase = "PICK_FACE" + self._active_vertex_idx = self.anchor_index + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(self.anchor_index, obj) + else: + self._phase = "PICK_VERTEX" + self._active_vertex_idx = -1 + self._hover_candidates = [] + self._hover_index = 0 + self._hover_last_px = (-9999, -9999) + self._hover_highlighted_obj = None + self._snap_mode = "FACE" + _snap_draw_data.clear() + self._draw_handler = bpy.types.SpaceView3D.draw_handler_add( + _draw_snap_indicator_global, (), "WINDOW", "POST_VIEW" + ) + + # When invoked from a panel, context.region_data is None. + # Walk the screen areas to find the actual 3D viewport region. + self._region, self._rv3d = None, None + for area in context.screen.areas: + if area.type == "VIEW_3D": + for region in area.regions: + if region.type == "WINDOW": + self._region = region + break + if area.spaces and area.spaces[0].type == "VIEW_3D": + self._rv3d = area.spaces[0].region_3d + break + + self._set_status(context) + context.window_manager.modal_handler_add(self) + return {"RUNNING_MODAL"} + + def _modal(self, context, event): + # Undo while the modal is running can free the annotation object. + try: + _ = self._annotation_obj.name + except ReferenceError: + self._cleanup(context) + return {"FINISHED"} + + if event.type == "ESC" or (event.type == "RIGHTMOUSE" and event.value == "PRESS"): + self._clear_hover_highlight(context) + context.workspace.status_text_set(None) + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(-1) + obj = context.active_object + if obj: + obj.select_set(False) + context.view_layer.objects.active = None + return {"FINISHED"} + + # Hover — recompute candidates as cursor moves (PICK_FACE phase only) + if event.type == "MOUSEMOVE" and self._phase == "PICK_FACE": + self._handle_hover(context, event) + return {"RUNNING_MODAL"} + + # Tab — cycle through candidates under cursor + if event.type == "TAB" and event.value == "PRESS" and self._phase == "PICK_FACE": + self._cycle_hover(context) + return {"RUNNING_MODAL"} + + if event.type == "LEFTMOUSE" and event.value == "PRESS": + if self._phase == "PICK_VERTEX": + self._handle_vertex_pick(context, event) + else: + wrote = self._handle_face_pick(context, event) + if wrote: + # Finish here so this anchor write is its own undo step. + # The dimension stays selected so the user can click + # another dot immediately. + self._cleanup(context) + return {"FINISHED"} + self._set_status(context) + return {"RUNNING_MODAL"} + + return {"PASS_THROUGH"} + + def _cleanup(self, context): + self._clear_hover_highlight(context) + context.workspace.status_text_set(None) + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(-1) + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + break + + # ------------------------------------------------------------------ + # Status bar + + def _set_status(self, context): + if self._phase == "PICK_VERTEX": + context.workspace.status_text_set( + "Click a dimension vertex | RMB / ESC: Finish" + ) + else: + # In PICK_FACE the hover handler writes a richer status; this is the + # fallback shown when no candidates have been computed yet. + context.workspace.status_text_set( + f"Vertex {self._active_vertex_idx} — hover over element | " + "TAB: cycle candidates | Click: anchor | ALT+Click: free point | RMB/ESC: Finish" + ) + + # ------------------------------------------------------------------ + # Phase 1: pick a vertex on the dimension curve + + def _handle_vertex_pick(self, context, event): + from bpy_extras import view3d_utils + + region = self._region + rv3d = self._rv3d + if not region or not rv3d: + return + + # event.mouse_region_x/y is relative to the event's region (e.g. N-panel), + # not our stored 3D viewport region. Use absolute coords minus region offset. + coord = (event.mouse_x - region.x, event.mouse_y - region.y) + obj = self._annotation_obj + + best_idx = None + best_dist_sq = self._VERTEX_PICK_RADIUS_PX ** 2 + + if obj.data and hasattr(obj.data, "splines"): + for spline in obj.data.splines: + for i, pt in enumerate(spline.points): + world_co = obj.matrix_world @ pt.co.xyz + screen_co = view3d_utils.location_3d_to_region_2d(region, rv3d, world_co) + if screen_co is None: + continue + dist_sq = (screen_co.x - coord[0]) ** 2 + (screen_co.y - coord[1]) ** 2 + if dist_sq < best_dist_sq: + best_dist_sq = dist_sq + best_idx = i + + if best_idx is None: + self.report({"WARNING"}, f"Click closer to a dimension vertex (within {self._VERTEX_PICK_RADIUS_PX}px)") + return + + self._active_vertex_idx = best_idx + self._phase = "PICK_FACE" + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(best_idx, obj) + + # ------------------------------------------------------------------ + # Phase 2: pick a face on an IFC element + + def _handle_face_pick(self, context, event): + from mathutils import Vector + + region = self._region + rv3d = self._rv3d + if not region or not rv3d: + return + + # ALT+click → free world-point anchor at any mesh surface. + if event.alt: + self._clear_hover_highlight(context) + origin, direction = self._unproject_coord( + (event.mouse_x - region.x, event.mouse_y - region.y) + ) + best_dist = float("inf") + alt_loc = None + for obj in context.scene.objects: + if obj.type != "MESH": + continue + try: + mx_inv = obj.matrix_world.inverted() + except Exception: + continue + ok, loc_l, _, _ = obj.ray_cast( + mx_inv @ origin, (mx_inv.to_3x3() @ direction).normalized() + ) + if ok: + loc_w = obj.matrix_world @ loc_l + d = (loc_w - origin).length + if d < best_dist: + best_dist = d + alt_loc = loc_w + pt_m = list(alt_loc) if alt_loc else list(origin + direction * 5.0) + import ifcopenshell.api.drawing as drawing_api + anchor = drawing_api.make_world_anchor(pt_m) + _do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache) + self.report({"INFO"}, f"Vertex {self._active_vertex_idx} → free world point") + return True + + # Normal click — use whichever candidate is currently highlighted. + self._clear_hover_highlight(context) + + coord = (event.mouse_x - region.x, event.mouse_y - region.y) + dx = coord[0] - self._hover_last_px[0] + dy = coord[1] - self._hover_last_px[1] + if dx * dx + dy * dy > self._HOVER_THROTTLE_PX_SQ or not self._hover_candidates: + self._hover_candidates = self._compute_candidates(context, coord) + self._hover_index = 0 + + if not self._hover_candidates: + self.report({"WARNING"}, "Nothing under cursor — click on a model element") + return + + idx = min(self._hover_index, len(self._hover_candidates) - 1) + hit_obj, hit_mesh, hit_mesh_mx, location, normal, face_index = self._hover_candidates[idx] + + element = tool.Ifc.get_entity(hit_obj) + if not element: + self.report({"WARNING"}, f"'{hit_obj.name}' is not an IFC element") + return + + file = tool.Ifc.get() + placement_override = {element.id(): np.array(hit_obj.matrix_world)} + + # Recompute snap geometry at the exact click position for accuracy. + snap = self._compute_snap_geom(hit_obj, face_index, coord) + snap_type = snap.get("type", "FACE") + + import ifcopenshell.api.drawing as drawing_api + try: + if snap_type == "LAYER" and snap.get("method") == "LAYER_BOUNDARY": + anchor = drawing_api.build_anchor_from_layer_boundary(file, element, snap) + elif snap_type == "VERTEX" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_vert(file, element, snap) + elif snap_type == "EDGE" and snap.get("profile_x_m") is not None: + anchor = drawing_api.build_anchor_from_profile_edge(file, element, snap) + elif snap_type in ("VERTEX", "EDGE") and snap.get("snap_world") is not None: + # Tessellation fallback — no IFC profile data available. + # Use the snap position as a static WORLD anchor rather than a + # FACE fingerprint, so the endpoint stays at the correct vertex/ + # edge position instead of drifting to the face centre. + sw = snap["snap_world"] + anchor = drawing_api.make_world_anchor([float(sw[0]), float(sw[1]), float(sw[2])]) + else: + hit_m = (float(location.x), float(location.y), float(location.z)) + normal_m = (float(normal.x), float(normal.y), float(normal.z)) + anchor = drawing_api.build_anchor_from_hit( + file, element, hit_m, normal_m, + shape_cache=self._shape_cache, + placement_override=placement_override, + ) + except Exception as exc: + import traceback + traceback.print_exc() + self.report({"ERROR"}, f"build_anchor failed: {exc}") + return + + _do_write_anchor(self._annotation, self._annotation_obj, anchor, self._active_vertex_idx, self._shape_cache) + self.report( + {"INFO"}, + f"Vertex {self._active_vertex_idx} → {element.is_a()}/{element.Name or element.GlobalId} [{anchor.get('type')}]", + ) + return True + + # ------------------------------------------------------------------ + # Hover / cycle helpers + + def _unproject_coord(self, coord): + """Return (origin, direction) world-space ray for a region pixel coord.""" + from mathutils import Vector + + rv3d = self._rv3d + region = self._region + persinv = rv3d.perspective_matrix.inverted() + dx = (2.0 * coord[0] / region.width) - 1.0 + dy = (2.0 * coord[1] / region.height) - 1.0 + near_h = persinv @ Vector((dx, dy, -1.0, 1.0)) + far_h = persinv @ Vector((dx, dy, 1.0, 1.0)) + origin = near_h.xyz / near_h.w + far_pt = far_h.xyz / far_h.w + direction = (far_pt - origin).normalized() + if not rv3d.is_perspective: + origin = origin - direction * 1e4 + return origin, direction + + def _compute_candidates(self, context, coord): + """Cast a ray from *coord* and return a ranked list of hit candidates. + + Each entry: (ifc_obj, hit_mesh, hit_mesh_mx, location, normal, face_index) + Sorted closest-first for direct hits; by proximity distance for near-misses. + """ + import math as _math + from mathutils import Vector + + origin, direction = self._unproject_coord(coord) + depsgraph = context.evaluated_depsgraph_get() + + # Scene-BVH pierce-through: O(log N) vs the previous O(N) per-object loop. + # Each iteration steps past the last hit surface to reach the next object. + direct: list = [] + ray_origin = Vector(origin) + _EPS = 1e-4 + + for _ in range(8): + result, loc_w, nrm_w, fi, hit_obj_eval, hit_mx = context.scene.ray_cast( + depsgraph, ray_origin, direction + ) + if not result: + break + ray_origin = loc_w + direction * _EPS + ifc_obj = getattr(hit_obj_eval, "original", hit_obj_eval) + if ifc_obj == self._annotation_obj: + continue + if not ifc_obj.visible_get(): + continue + if ifc_obj.type != "MESH": + continue + if not tool.Ifc.get_entity(ifc_obj): + continue + mx = ifc_obj.matrix_world + fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx) + normal = ( + (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() + if fi is not None + else nrm_w.normalized() + ) + dist = (loc_w - origin).length + direct.append((dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) + if direct: + direct.sort(key=lambda c: c[0]) + return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in direct] + + # Proximity fallback — collect ALL candidates within TOL, sorted by perp distance. + TOL = 0.05 + + def _perp(v): + return v - v.dot(direction) * direction + + prox: list = [] + for ifc_obj in context.scene.objects: + if ifc_obj == self._annotation_obj: + continue + if not ifc_obj.visible_get(): + continue + if not tool.Ifc.get_entity(ifc_obj): + continue + if ifc_obj.type != "MESH": + continue + mx = ifc_obj.matrix_world + try: + mx_inv = mx.inverted() + except Exception: + continue + bb_world = [mx @ Vector(c) for c in ifc_obj.bound_box] + bb_proj = [_perp(v) for v in bb_world] + op = _perp(origin) + sx = max(min(v.x for v in bb_proj) - op.x, 0.0, op.x - max(v.x for v in bb_proj)) + sy = max(min(v.y for v in bb_proj) - op.y, 0.0, op.y - max(v.y for v in bb_proj)) + sz = max(min(v.z for v in bb_proj) - op.z, 0.0, op.z - max(v.z for v in bb_proj)) + perp_dist = _math.sqrt(sx * sx + sy * sy + sz * sz) + if perp_dist > TOL: + continue + bb_ctr = sum((v for v in bb_world), Vector()) / 8 + t = (bb_ctr - origin).dot(direction) + query_w = origin + t * direction + try: + found, loc_l, nrm_l, fi = ifc_obj.closest_point_on_mesh(mx_inv @ query_w, distance=100.0) + except RuntimeError: + continue + if not found: + continue + loc_w = mx @ loc_l + fi = _prefer_perp_face_index(ifc_obj, loc_w, fi, world_matrix=mx) + normal = (mx.to_3x3() @ ifc_obj.data.polygons[fi].normal).normalized() if fi is not None else (mx.to_3x3() @ nrm_l).normalized() + prox.append((perp_dist, ifc_obj, ifc_obj, mx, loc_w, normal, fi)) + + prox.sort(key=lambda c: c[0]) + return [(o, m, mmx, l, n, f) for _, o, m, mmx, l, n, f in prox] + + def _handle_hover(self, context, event): + """Recompute candidates when cursor moves; highlight the current one.""" + if not self._region: + return + coord = (event.mouse_x - self._region.x, event.mouse_y - self._region.y) + dx = coord[0] - self._hover_last_px[0] + dy = coord[1] - self._hover_last_px[1] + if dx * dx + dy * dy < self._HOVER_THROTTLE_PX_SQ: + return + self._hover_last_px = coord + self._hover_candidates = self._compute_candidates(context, coord) + self._hover_index = 0 + self._apply_hover_highlight(context) + + def _cycle_hover(self, context): + """Cycle snap mode (FACE → EDGE → VERTEX); advance element on wrap-around.""" + if not self._hover_candidates: + return + modes = self._SNAP_MODES + cur = modes.index(self._snap_mode) + nxt = (cur + 1) % len(modes) + self._snap_mode = modes[nxt] + if nxt == 0 and len(self._hover_candidates) > 1: + self._hover_index = (self._hover_index + 1) % len(self._hover_candidates) + self._apply_hover_highlight(context) + + def _apply_hover_highlight(self, context): + """Select the current candidate object; compute snap geometry; update status.""" + if not self._hover_candidates: + self._clear_hover_highlight(context) + _snap_draw_data.clear() + return + + ifc_obj, _, _, _, _, face_index = self._hover_candidates[self._hover_index] + + # Only update selection when the highlighted object changes. + if ifc_obj != self._hover_highlighted_obj: + if self._hover_highlighted_obj: + try: + self._hover_highlighted_obj.select_set(False) + except Exception: + pass + self._hover_highlighted_obj = ifc_obj + try: + ifc_obj.select_set(True) + context.view_layer.objects.active = ifc_obj + except Exception: + pass + + _snap_draw_data.clear() + _snap_draw_data.update(self._compute_snap_geom(ifc_obj, face_index, self._hover_last_px)) + + entity = tool.Ifc.get_entity(ifc_obj) + label = (entity.Name or entity.GlobalId) if entity else ifc_obj.name + n = len(self._hover_candidates) + mode_label = self._snap_mode.capitalize() + elem_hint = f" ({self._hover_index + 1}/{n})" if n > 1 else "" + context.workspace.status_text_set( + f"Dim vertex {self._active_vertex_idx} — {label} [{mode_label}{elem_hint}]" + " | TAB: cycle snap | Click: anchor | ALT+Click: free | RMB/ESC: Finish" + ) + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + break + + def _clear_hover_highlight(self, context): + """Deselect the highlighted object and restore the annotation as active.""" + if self._hover_highlighted_obj: + try: + self._hover_highlighted_obj.select_set(False) + except Exception: + pass + self._hover_highlighted_obj = None + try: + self._annotation_obj.select_set(True) + context.view_layer.objects.active = self._annotation_obj + except Exception: + pass + + def cancel(self, context): + """Called when the operator is cancelled externally — clean up GPU handler.""" + _snap_draw_data.clear() + if self._draw_handler: + bpy.types.SpaceView3D.draw_handler_remove(self._draw_handler, "WINDOW") + self._draw_handler = None + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(-1) + obj = context.active_object + if obj: + obj.select_set(False) + context.view_layer.objects.active = None + + # ------------------------------------------------------------------ + # Snap geometry helpers + + def _compute_snap_geom(self, hit_obj, face_index, coord) -> dict: + """Return snap draw-data dict for the current snap mode and hit face. + + When the hit element has an IfcExtrudedAreaSolid, VERTEX and EDGE snaps + are resolved from the IFC profile geometry (stable across mesh reloads) + rather than from Blender tessellation vertex indices. + + ``coord`` is a (x, y) tuple in region-local pixels. Returns an empty + dict when the face index is invalid or the region is unavailable. + """ + from bpy_extras.view3d_utils import location_3d_to_region_2d + + region = self._region + rv3d = self._rv3d + if not region or not rv3d or face_index is None: + return {} + try: + face = hit_obj.data.polygons[face_index] + except (IndexError, AttributeError): + return {} + + mx = hit_obj.matrix_world + face_verts_world = [tuple(mx @ hit_obj.data.vertices[vi].co) for vi in face.vertices] + + if self._snap_mode == "FACE": + return {"type": "FACE", "face_verts": face_verts_world} + + # Try profile-based snap candidates first (IFC-native, index-stable). + if self._snap_mode in ("VERTEX", "EDGE"): + element = tool.Ifc.get_entity(hit_obj) + if element: + import ifcopenshell.api.drawing as drawing_api + placement_override = {element.id(): np.array(mx)} + candidates = drawing_api.get_profile_snap_candidates( + tool.Ifc.get(), element, placement_override=placement_override + ) + want_type = self._snap_mode + best_cand = None + best_d2 = float("inf") + for cand in candidates: + if cand["type"] != want_type: + continue + sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"]) + if sp is None: + continue + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_cand = d2, cand + if best_cand is not None: + return best_cand + + # Fallback: Blender tessellation snap for elements without an + # IfcExtrudedAreaSolid profile. Returns snap_world for the visual + # indicator; no pt_idx so the click handler creates a face anchor. + screen_pts = [location_3d_to_region_2d(region, rv3d, wv) for wv in face_verts_world] + n = len(face_verts_world) + + if self._snap_mode == "VERTEX": + best_i, best_d2 = 0, float("inf") + for i, sp in enumerate(screen_pts): + if sp is not None: + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_i = d2, i + return {"type": "VERTEX", "snap_world": face_verts_world[best_i]} + + if self._snap_mode == "EDGE": + best_e, best_d2 = 0, float("inf") + for i in range(n): + j = (i + 1) % n + sp0, sp1 = screen_pts[i], screen_pts[j] + if sp0 is not None and sp1 is not None: + mid_x = (sp0.x + sp1.x) * 0.5 + mid_y = (sp0.y + sp1.y) * 0.5 + dx, dy = mid_x - coord[0], mid_y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_e = d2, i + i0, i1 = best_e, (best_e + 1) % n + v0_w, v1_w = face_verts_world[i0], face_verts_world[i1] + mid_w = ((v0_w[0] + v1_w[0]) * 0.5, (v0_w[1] + v1_w[1]) * 0.5, (v0_w[2] + v1_w[2]) * 0.5) + return {"type": "EDGE", "v0": v0_w, "v1": v1_w, "snap_world": mid_w} + + if self._snap_mode == "LAYER": + element = tool.Ifc.get_entity(hit_obj) + if element: + import ifcopenshell.api.drawing as drawing_api + placement_override = {element.id(): np.array(mx)} + candidates = drawing_api.get_layer_snap_candidates( + tool.Ifc.get(), element, placement_override=placement_override + ) + best_cand = None + best_d2 = float("inf") + for cand in candidates: + sp = location_3d_to_region_2d(region, rv3d, cand["snap_world"]) + if sp is None: + continue + dx, dy = sp.x - coord[0], sp.y - coord[1] + d2 = dx * dx + dy * dy + if d2 < best_d2: + best_d2, best_cand = d2, cand + if best_cand is not None: + result = dict(best_cand) + result["type"] = "LAYER" + result["method"] = "LAYER_BOUNDARY" + return result + return {"type": "FACE", "face_verts": face_verts_world} + + return {} + + +def _do_write_anchor(annotation, annotation_obj, new_anchor: dict, vertex_index: int, shape_cache=None) -> None: + """Write one anchor into the BBIM_Dimension pset and regenerate the curve.""" + file = tool.Ifc.get() + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + + if pset_data and pset_data.get("Anchors"): + try: + anchors: list = json.loads(pset_data["Anchors"]) + except Exception: + anchors = [] + else: + anchors = _anchors_from_spline(annotation_obj, file) + + while len(anchors) <= vertex_index: + idx = len(anchors) + if annotation_obj and annotation_obj.data and hasattr(annotation_obj.data, "splines") and annotation_obj.data.splines: + pts = annotation_obj.data.splines[0].points + if idx < len(pts): + co = annotation_obj.matrix_world @ pts[idx].co.xyz + import ifcopenshell.api.drawing as drawing_api + anchors.append(drawing_api.make_world_anchor([float(co.x), float(co.y), float(co.z)])) + continue + import ifcopenshell.api.drawing as drawing_api + anchors.append(drawing_api.make_world_anchor([0.0, 0.0, 0.0])) + + anchors[vertex_index] = new_anchor + anchors_json = json.dumps(anchors) + + if pset_data: + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) + else: + ifcopenshell.api.run("pset.add_pset", file, product=annotation, name="BBIM_Dimension") + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.run("pset.edit_pset", file, pset=pset_entity, properties={"Anchors": anchors_json}) + + from bonsai.bim.module.drawing import handler as _drawing_handler + _drawing_handler.invalidate_dim_index() + + placement_override: dict = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + import ifcopenshell.api.drawing as drawing_api + resolved_pts = drawing_api.regenerate_dimension( + file, + annotation, + shape_cache=shape_cache, + placement_override=placement_override, + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + + + +class RegenerateDimensions(bpy.types.Operator, tool.Ifc.Operator): + """Regenerate all parametric dimension annotations in the project. + + For every IfcAnnotation that has a BBIM_Dimension pset, resolve all + anchor references from live element geometry and update the annotation's + curve vertices and linked IfcMetric values. + """ + + bl_idname = "bim.regenerate_dimensions" + bl_label = "Regenerate Dimensions" + bl_description = ( + "Recompute all parametric dimension annotations from current element geometry.\n" + "Updates curve vertex positions and IfcMetric segment values." + ) + bl_options = {"REGISTER", "UNDO"} + + active_only: bpy.props.BoolProperty( + name="Active Only", + description="Only regenerate the currently selected dimension annotation", + default=False, + ) + + if TYPE_CHECKING: + active_only: bool + + @classmethod + def poll(cls, context): + return bool(tool.Ifc.get()) + + def _execute(self, context): + import ifcopenshell.api.drawing as drawing_api + import ifcopenshell.geom + + file = tool.Ifc.get() + + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("APPLY_DEFAULT_MATERIALS", False) + shape_cache: dict = {} + + if self.active_only: + obj = context.active_object + if not obj: + self.report({"WARNING"}, "No active object.") + return + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + self.report({"WARNING"}, "Active object is not an IfcAnnotation.") + return + candidates = [element] + else: + candidates = [ + a for a in file.by_type("IfcAnnotation") + if ifcopenshell.util.element.get_pset(a, "BBIM_Dimension") + ] + + from bonsai.bim.module.drawing.handler import _sync_dimension_anchors_to_curve + + updated = 0 + for annotation in candidates: + pset = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset: + continue + + # Sync anchor count to curve vertex count in case the user added or + # removed vertices in Edit Mode since the last regeneration. + ann_obj = tool.Ifc.get_object(annotation) + if ann_obj and ann_obj.type == "CURVE": + _sync_dimension_anchors_to_curve(file, annotation, ann_obj) + + # Build a placement override from each referenced element's current + # Blender matrix_world. Bonsai only syncs ObjectPlacement to the IFC + # file when the user explicitly clicks "Edit Object Placement" — so the + # IFC entity may be stale after a viewport G-move. Using matrix_world + # ensures we always see the current element position. + placement_override: dict[int, "np.ndarray"] = {} + try: + anchors_raw = json.loads(pset.get("Anchors") or "[]") + for anchor in anchors_raw: + guid = anchor.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_id = elem.id() + if elem_id in placement_override: + continue + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem_id] = np.array(elem_obj.matrix_world) + except Exception: + pass + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension( + file, annotation, + settings=geom_settings, + shape_cache=shape_cache, + placement_override=placement_override, + ) + if not resolved_pts: + continue + + _update_blender_curve(annotation, resolved_pts) + updated += 1 + + self.report({"INFO"}, f"Regenerated {updated} parametric dimension(s).") + + +# --------------------------------------------------------------------------- +# Helpers for dimension operators +# --------------------------------------------------------------------------- + + +def _anchors_from_spline(obj: bpy.types.Object, file: ifcopenshell.file) -> list: + """Build a list of WORLD anchors from the current spline points of obj. + + Coordinates are stored in metres (Blender world space), which matches the + output of ifcopenshell.geom.create_shape regardless of IFC project unit. + """ + import ifcopenshell.api.drawing as drawing_api + + anchors = [] + if not obj or not obj.data or not hasattr(obj.data, "splines") or not obj.data.splines: + return anchors + + for pt in obj.data.splines[0].points: + world_co = obj.matrix_world @ pt.co.xyz + # Store in metres (Blender world space) + pt_m = [float(world_co.x), float(world_co.y), float(world_co.z)] + anchors.append(drawing_api.make_world_anchor(pt_m)) + + return anchors + + +def _update_blender_curve( + annotation: ifcopenshell.entity_instance, + resolved_pts_m: list, +) -> None: + """Update a Blender curve object's spline points AND the backing IFC IfcPolyline. + + :param resolved_pts_m: Points in metres (Blender world space). + + Both the Blender curve data and the IFC representation are updated so that + entering Edit Mode (which reloads geometry from IFC via import_representation_items) + does not reset the curve back to pre-regeneration positions. + """ + obj = tool.Ifc.get_object(annotation) + if not obj or not obj.data or not hasattr(obj.data, "splines"): + return + + curve_data: bpy.types.Curve = obj.data + inv_world = obj.matrix_world.inverted() + n = len(resolved_pts_m) + + if not curve_data.splines: + spline = curve_data.splines.new("POLY") + spline.points.add(n - 1) + else: + spline = curve_data.splines[0] + if len(spline.points) != n: + curve_data.splines.remove(spline) + spline = curve_data.splines.new("POLY") + spline.points.add(n - 1) + + is_2d = _annotation_is_2d(annotation) + for i, pt_m in enumerate(resolved_pts_m): + blender_world = Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2]))) + local_pt = inv_world @ blender_world + # For 2D (plan-view) annotations, project onto the annotation plane by + # zeroing local Z — matching the Annotator.add_line_to_annotation pattern. + if is_2d: + spline.points[i].co = (local_pt.x, local_pt.y, 0.0, 1.0) + else: + spline.points[i].co = (*local_pt, 1.0) + + # Also update the IFC IfcPolyline so Edit Mode reloads reflect the new positions. + try: + _update_ifc_polyline(tool.Ifc.get(), annotation, obj, resolved_pts_m) + except Exception as e: + pass + + +def _annotation_is_2d(annotation: ifcopenshell.entity_instance) -> bool: + """Return True if the annotation's representation uses 2D coordinates (plan view).""" + if not getattr(annotation, "Representation", None): + return False + for rep in annotation.Representation.Representations: + curve = _find_curve_item(rep) + if curve is None: + continue + if curve.is_a("IfcIndexedPolyCurve"): + return curve.Points.is_a("IfcCartesianPointList2D") + if curve.is_a("IfcPolyline") and curve.Points: + return len(curve.Points[0].Coordinates) == 2 + return False + + +def _update_ifc_polyline( + file: ifcopenshell.file, + annotation: ifcopenshell.entity_instance, + obj: bpy.types.Object, + resolved_pts_m: list, +) -> None: + """Update the curve coordinates in the annotation's IFC representation. + + Converts world-space metres points → annotation-local IFC project units and + writes them into the existing IfcIndexedPolyCurve or IfcPolyline entities. + Handles both 2D (IfcCartesianPointList2D) and 3D representations. + """ + if not resolved_pts_m or not getattr(annotation, "Representation", None): + return + + import ifcopenshell.util.unit as ifc_unit + + unit_scale = ifc_unit.calculate_unit_scale(file) + inv_world = obj.matrix_world.inverted() + + def _to_ifc_local(pt_m: tuple) -> tuple: + blender_local = inv_world @ Vector((float(pt_m[0]), float(pt_m[1]), float(pt_m[2]))) + return ( + float(blender_local.x) / unit_scale, + float(blender_local.y) / unit_scale, + float(blender_local.z) / unit_scale, + ) + + new_coords = [_to_ifc_local(pt) for pt in resolved_pts_m] + + for rep in annotation.Representation.Representations: + curve = _find_curve_item(rep) + if curve is None: + continue + + if curve.is_a("IfcIndexedPolyCurve"): + pts_list = curve.Points # IfcCartesianPointList2D or 3D + n_dims = 2 if pts_list.is_a("IfcCartesianPointList2D") else 3 + pts_list.CoordList = tuple(coords[:n_dims] for coords in new_coords) + # Rebuild Segments to cover all consecutive pairs. Bonsai creates + # explicit IfcLineIndex entries per segment; leaving a stale Segments + # list (e.g. [IfcLineIndex([1,2])]) after adding a 3rd point means + # the extra point is silently ignored on geometry reload. + n_pts = len(new_coords) + if n_pts >= 2: + curve.Segments = [file.createIfcLineIndex([i + 1, i + 2]) for i in range(n_pts - 1)] + else: + curve.Segments = None + return + + if curve.is_a("IfcPolyline"): + existing = list(curve.Points) + if len(existing) == len(new_coords): + for ifc_pt, coords in zip(existing, new_coords): + n_dims = len(ifc_pt.Coordinates) + ifc_pt.Coordinates = coords[:n_dims] + else: + dim = len(existing[0].Coordinates) if existing else 3 + curve.Points = [ + file.create_entity("IfcCartesianPoint", Coordinates=coords[:dim]) + for coords in new_coords + ] + return + + +def _find_curve_item(rep: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]: + """Return the first IfcPolyline or IfcIndexedPolyCurve in a shape representation.""" + for item in rep.Items: + result = _find_curve_in_item(item) + if result is not None: + return result + return None + + +def _find_curve_in_item(item: ifcopenshell.entity_instance) -> Optional[ifcopenshell.entity_instance]: + if item.is_a("IfcPolyline") or item.is_a("IfcIndexedPolyCurve"): + return item + if item.is_a("IfcGeometricCurveSet"): + for element in item.Elements: + result = _find_curve_in_item(element) + if result is not None: + return result + return None + + + + +class ClickNearestDimensionAnchor(bpy.types.Operator): + """LMB fallback: fire SetDimensionAnchor when cursor is within RADIUS pixels of an anchor dot. + + The gizmo handles exact hits; this catches near-misses where the cursor + is close to a dot but didn't land inside the gizmo hit shape. + """ + + bl_idname = "bim.click_nearest_dimension_anchor" + bl_label = "Click Nearest Dimension Anchor" + + RADIUS_PX = 60 + + def invoke(self, context, event): + from bpy_extras.view3d_utils import location_3d_to_region_2d + + if not tool.Ifc.get(): + return {"PASS_THROUGH"} + + # Always use the 3D viewport WINDOW region — context.region may be a header, + # sidebar, or toolbar depending on where the click landed in the area. + region = None + rv3d = None + for area in context.screen.areas: + if area.type != "VIEW_3D": + continue + for r in area.regions: + if r.type == "WINDOW": + region = r + break + if region: + for space in area.spaces: + if space.type == "VIEW_3D": + rv3d = space.region_3d + break + break + + if not region or not rv3d: + return {"PASS_THROUGH"} + + # Convert absolute mouse position to WINDOW region-local coordinates. + cx = event.mouse_x - region.x + cy = event.mouse_y - region.y + + import ifcopenshell.util.element as _ue + + r2 = self.RADIUS_PX ** 2 + best_obj = None + best_idx = -1 + best_dist_sq = float("inf") + + # Scan all visible dimension annotations — not just selected ones. + # view3d.select may deselect the annotation before this operator runs. + for obj in context.scene.objects: + if obj.type != "CURVE": + continue + if not obj.visible_get(): + continue + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + continue + pset = _ue.get_pset(element, "BBIM_Dimension") + if not pset or not pset.get("Anchors"): + continue + if not obj.data.splines: + continue + + for i, pt in enumerate(obj.data.splines[0].points): + world_pos = obj.matrix_world @ pt.co.to_3d() + sp = location_3d_to_region_2d(region, rv3d, world_pos) + if not sp: + continue + dx, dy = cx - sp.x, cy - sp.y + d2 = dx * dx + dy * dy + if d2 < r2 and d2 < best_dist_sq: + best_dist_sq = d2 + best_idx = i + best_obj = obj + + if best_obj is None: + return {"PASS_THROUGH"} + + for o in list(context.selected_objects): + o.select_set(False) + best_obj.select_set(True) + context.view_layer.objects.active = best_obj + from bonsai.bim.module.drawing.gizmos import set_active_anchor + set_active_anchor(best_idx, best_obj) + # Force viewport redraw so gizmo colors update before the modal starts. + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + break + bpy.ops.bim.set_dimension_anchor("INVOKE_DEFAULT", anchor_index=best_idx) + return {"FINISHED"} + + +class DebugDimensionClicks(bpy.types.Operator): + """Debug modal: logs every LMB click in the 3D viewport vs anchor gizmo positions. + + Run from the Python console: + bpy.ops.bim.debug_dimension_clicks('INVOKE_DEFAULT') + Press ESC or RMB to stop. + """ + + bl_idname = "bim.debug_dimension_clicks" + bl_label = "Debug Dimension Clicks" + + def modal(self, context, event): + if event.type == "LEFTMOUSE" and event.value == "PRESS": + # Use absolute window coords — mouse_region_x/y is relative to whichever + # region caught the event, which may differ from the 3D viewport region. + click_x = event.mouse_x + click_y = event.mouse_y + + # Find the 3D viewport region — context.region_data is None in modal. + region = None + rv3d = None + for area in context.screen.areas: + if area.type != "VIEW_3D": + continue + for r in area.regions: + if r.type == "WINDOW": + region = r + break + if region: + for space in area.spaces: + if space.type == "VIEW_3D": + rv3d = space.region_3d + break + break + + obj = context.active_object + if obj and obj.type == "CURVE" and obj.data and obj.data.splines and region and rv3d: + from bpy_extras.view3d_utils import location_3d_to_region_2d + + print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) region_offset=({region.x},{region.y})") + for i, pt in enumerate(obj.data.splines[0].points): + world_pos = obj.matrix_world @ pt.co.to_3d() + screen_pos = location_3d_to_region_2d(region, rv3d, world_pos) + if screen_pos: + # Convert region-local pos to absolute window coords for comparison. + abs_x = screen_pos.x + region.x + abs_y = screen_pos.y + region.y + dist = ((click_x - abs_x) ** 2 + (click_y - abs_y) ** 2) ** 0.5 + print(f" anchor[{i}] abs={abs_x:.0f},{abs_y:.0f} dist={dist:.1f}px") + else: + print(f" anchor[{i}] (off screen)") + else: + print(f"[ClickDebug] LMB at abs ({click_x}, {click_y}) — no active curve or no 3D region") + return {"PASS_THROUGH"} + + if event.type in {"ESC", "RIGHTMOUSE"}: + print("[ClickDebug] Stopped.") + return {"CANCELLED"} + + return {"PASS_THROUGH"} + + def invoke(self, context, event): + context.window_manager.modal_handler_add(self) + print("[ClickDebug] Dimension click logger started. Click near anchor dots; press ESC to stop.") + return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index 702f04258e..8e779cdb60 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -27,7 +27,6 @@ import ifcopenshell.api.pset import ifcopenshell.util.element from bpy.props import ( BoolProperty, - BoolVectorProperty, CollectionProperty, EnumProperty, FloatProperty, @@ -861,13 +860,13 @@ class BIMTextProperties(PropertyGroup): is_editing: BoolProperty(name="Is Editing", default=False) literals: CollectionProperty(name="Literals", type=LiteralProps) newline_at: IntProperty(name="Newline At") - symbol: EnumProperty( # pyright: ignore[reportRedeclaration] + symbol: EnumProperty( name="Symbol", description="Symbol from symbols.svg to use for this text.", items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS], default="NO SYMBOL", ) - custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration] + custom_symbol: StringProperty( name="Custom Symbol", description="Non-default symbol to use for this text.", ) @@ -987,6 +986,160 @@ def update_sheet_data(self, context): SheetsData.is_loaded = False +def _update_force_perpendicular(self, context): + """Apply ForcePerpendicularToFace to all selected dimension annotations and regenerate them.""" + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.pset + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as tool + + file = tool.Ifc.get() + if not file: + return + + new_value = self.force_perpendicular_to_face + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + + targets = [] + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + continue + if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES: + continue + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") + if not pset_data: + continue + targets.append((obj, element, pset_data)) + + if not targets: + return + + from bonsai.bim.module.drawing.operator import _update_blender_curve + + for obj, element, pset_data in targets: + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"ForcePerpendicularToFace": new_value}) + + anchors = json.loads(pset_data.get("Anchors") or "[]") + placement_override = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) + if resolved_pts: + _update_blender_curve(element, resolved_pts) + + +def _get_line_position(self) -> float: + """Return LinePosition from the active annotation's BBIM_Dimension pset. + + Falls back to the natural anchor projection when LinePosition has not been + explicitly set, so the field always shows a meaningful value. + """ + import math + import json + try: + import bpy as _bpy + import ifcopenshell.util.element as _ue + import bonsai.tool as _tool + obj = getattr(_bpy.context, "active_object", None) + if obj: + element = _tool.Ifc.get_entity(obj) + if element and element.is_a("IfcAnnotation"): + pset = _ue.get_pset(element, "BBIM_Dimension") + if pset: + stored = pset.get("LinePosition") + if stored is not None: + return float(stored) + raw = pset.get("Anchors") + if raw: + anchors = json.loads(raw) + if len(anchors) >= 2 and anchors[0].get("pt") and anchors[1].get("pt"): + a, b = anchors[0]["pt"], anchors[1]["pt"] + dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + m = math.sqrt(dx * dx + dy * dy + dz * dz) + if m > 1e-10: + ddx, ddy, ddz = dx / m, dy / m, dz / m + # cross(world_Z=(0,0,1), dim_dir) = (-ddy, ddx, 0) + ox, oy, oz = -ddy, ddx, 0.0 + om = math.sqrt(ox * ox + oy * oy) + if om > 1e-6: + od = (ox / om, oy / om, 0.0) + pt = anchors[0]["pt"] + return float(pt[0] * od[0] + pt[1] * od[1]) + except Exception: + pass + return 0.0 + + +def _set_line_position(self, value: float) -> None: + """Write LinePosition to all selected dimension annotations and regenerate.""" + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.pset + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as tool + + file = tool.Ifc.get() + if not file: + return + + _DIM_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + + targets = [] + import bpy as _bpy + for obj in getattr(_bpy.context, "selected_objects", []): + element = tool.Ifc.get_entity(obj) + if not element or not element.is_a("IfcAnnotation"): + continue + if ifcopenshell.util.element.get_predefined_type(element) not in _DIM_TYPES: + continue + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") + if not pset_data: + continue + targets.append((obj, element, pset_data)) + + if not targets: + return + + from bonsai.bim.module.drawing.operator import _update_blender_curve + + for obj, element, pset_data in targets: + pset_entity = file.by_id(pset_data["id"]) + ifcopenshell.api.pset.edit_pset(file, pset=pset_entity, properties={"LinePosition": value}) + + anchors = json.loads(pset_data.get("Anchors") or "[]") + placement_override = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension(file, element, placement_override=placement_override) + if resolved_pts: + _update_blender_curve(element, resolved_pts) + + class BIMAnnotationProperties(PropertyGroup): object_type: bpy.props.EnumProperty( name="Annotation Object Type", items=annotation_classes, default="TEXT", update=update_annotation_object_type @@ -1000,6 +1153,19 @@ class BIMAnnotationProperties(PropertyGroup): ) is_adding_type: bpy.props.BoolProperty(default=False) type_name: bpy.props.StringProperty(name="Name", default="TYPEX") + force_perpendicular_to_face: bpy.props.BoolProperty( + name="Force ⊄ to Face", + description="Constrain dimension vertices to the face normal of the first anchor. When dimensions are selected, toggling this updates them all.", + default=False, + update=_update_force_perpendicular, + ) + line_position: bpy.props.FloatProperty( + name="Line Position", + description="Absolute world position of the dimension line along the horizontal axis perpendicular to the dimension. The line is held at this fixed global coordinate even when the measured geometry moves. Updates all selected dimensions.", + unit="LENGTH", + get=_get_line_position, + set=_set_line_position, + ) is_manual_reference: bpy.props.BoolProperty( name="Is a Reference", default=False, diff --git a/src/bonsai/bonsai/bim/module/drawing/shaders.py b/src/bonsai/bonsai/bim/module/drawing/shaders.py index 774002fea3..c050cf0f41 100644 --- a/src/bonsai/bonsai/bim/module/drawing/shaders.py +++ b/src/bonsai/bonsai/bim/module/drawing/shaders.py @@ -366,9 +366,6 @@ class BaseLinesShader(BaseShader): } """ - def __init__(self, gap_size=16): - super().__init__(gap_size=gap_size) - def glenable(self): super().glenable() diff --git a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py index 37c79474d0..fa5088f9d9 100644 --- a/src/bonsai/bonsai/bim/module/drawing/svgwriter.py +++ b/src/bonsai/bonsai/bim/module/drawing/svgwriter.py @@ -1397,14 +1397,18 @@ class SvgWriter: def get_text(): radius = (points[-1].co - points[-2].co).length - radius = helper.format_distance( - radius, - precision=self.precision, - decimal_places=self.decimal_places, - custom_unit=dimension_data["custom_unit"], - ) - text = f"R{radius}" - return text + units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None] + parts = [ + helper.format_distance( + radius, + precision=self.precision, + decimal_places=self.decimal_places, + suppress_zero_feet=dimension_data["suppress_zero_feet"], + custom_unit=unit, + ) + for unit in units_to_format + ] + return "R" + dimension_data["separator"].join(str(p) for p in parts) self.draw_dimension_text( get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center" @@ -1529,10 +1533,12 @@ class SvgWriter: text_format=lambda x: "D" + x, show_description_only=dimension_data["show_description_only"], suppress_zero_inches=dimension_data["suppress_zero_inches"], + suppress_zero_feet=dimension_data["suppress_zero_feet"], text_prefix=dimension_data["text_prefix"], text_suffix=dimension_data["text_suffix"], fill_bg=dimension_data["fill_bg"], - custom_unit=dimension_data["custom_unit"], + custom_units=dimension_data["custom_units"], + separator=dimension_data["separator"], ) def draw_dimension_annotations(self, obj: bpy.types.Object) -> None: @@ -1543,11 +1549,15 @@ class SvgWriter: dimension_data = DecoratorData.get_dimension_data(obj) assert isinstance(obj.data, bpy.types.Curve) + is_ordinate = dimension_data["is_ordinate"] for spline in obj.data.splines: points = self.get_spline_points(spline) + ordinate_total = 0.0 for i in range(len(points) - 1): v0_global = matrix_world @ points[i].co.xyz v1_global = matrix_world @ points[i + 1].co.xyz + if is_ordinate: + ordinate_total += (v1_global - v0_global).length self.draw_dimension_annotation( v0_global, v1_global, @@ -1555,10 +1565,13 @@ class SvgWriter: dimension_text=dimension_text, show_description_only=dimension_data["show_description_only"], suppress_zero_inches=dimension_data["suppress_zero_inches"], + suppress_zero_feet=dimension_data["suppress_zero_feet"], text_prefix=dimension_data["text_prefix"], text_suffix=dimension_data["text_suffix"], fill_bg=dimension_data["fill_bg"], - custom_unit=dimension_data["custom_unit"], + custom_units=dimension_data["custom_units"], + separator=dimension_data["separator"], + distance_override=ordinate_total if is_ordinate else None, ) def draw_measureit_arch_dimension_annotations(self) -> None: @@ -1582,10 +1595,13 @@ class SvgWriter: text_format=lambda x: x, show_description_only=False, suppress_zero_inches=False, + suppress_zero_feet=False, text_prefix="", text_suffix="", fill_bg=False, - custom_unit=None, + custom_units=None, + separator=" / ", + distance_override=None, ) -> None: offset = Vector([self.raw_width, self.raw_height]) / 2 v0 = self.project_point_onto_camera(v0_global) @@ -1598,7 +1614,10 @@ class SvgWriter: sheet_dimension = (end - start).length # if annotation can't fit offset text to the right of marker - text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized())) + if distance_override is not None: + text_position = end + else: + text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized())) angle = math.degrees(vector.angle_signed(Vector((1, 0)))) line = self.svg.line(start=start, end=end, class_=" ".join(classes)) @@ -1613,15 +1632,20 @@ class SvgWriter: } if not show_description_only: - dimension = (v1_global - v0_global).length - dimension = helper.format_distance( - dimension, - precision=self.precision, - decimal_places=self.decimal_places, - suppress_zero_inches=suppress_zero_inches, - custom_unit=custom_unit, - ) - text = text_prefix + str(dimension) + text_suffix + dimension = distance_override if distance_override is not None else (v1_global - v0_global).length + units_to_format = custom_units if custom_units else [None] + parts = [ + helper.format_distance( + dimension, + precision=self.precision, + decimal_places=self.decimal_places, + suppress_zero_inches=suppress_zero_inches, + suppress_zero_feet=suppress_zero_feet, + custom_unit=unit, + ) + for unit in units_to_format + ] + text = text_prefix + separator.join(str(p) for p in parts) + text_suffix else: if not dimension_text: return @@ -1629,8 +1653,8 @@ class SvgWriter: text_tags += self.create_text_tag( text, - text_position + perpendicular, - box_alignment="bottom-middle", + text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))), + box_alignment="bottom-right" if distance_override is not None else "bottom-middle", multiline_to_bottom=False, **text_tag_kwargs, ) @@ -1638,8 +1662,8 @@ class SvgWriter: if not show_description_only and dimension_text: text_tags += self.create_text_tag( dimension_text, - text_position - perpendicular, - box_alignment="top-middle", + text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))), + box_alignment="top-right" if distance_override is not None else "top-middle", multiline_to_bottom=True, **text_tag_kwargs, ) diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 6d008f5e6b..e3a2deb447 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -563,6 +563,7 @@ class BIM_PT_product_assignments(Panel): col.enabled = bool(ProductAssignmentsData.data["relating_product"]) + def get_category_icon(category_name): """Get appropriate icon for each category""" icons = { @@ -827,7 +828,6 @@ class BIM_PT_text(Panel): for i, literal_data in enumerate(text_data["Literals"]): box = self.layout.box() - box.label(text=f"Literal[{i}]:") # Combine both approaches: clickable attributes from PR #7292 and display from PR #7106 for attribute in literal_data: diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index 0d3470134d..a28f2f32e1 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -114,7 +114,11 @@ class AnnotationTool(WorkSpaceTool): bl_description = "Gives you Annotation related superpowers" bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.annotation") bl_widget = None - bl_keymap = tool.Blender.get_default_selection_keypmap() + ( + bl_keymap = ( + # Before view3d.select: tool keymaps take priority over the addon keymap + # where ClickNearestDimensionAnchor is also registered. + ("bim.click_nearest_dimension_anchor", {"type": "LEFTMOUSE", "value": "PRESS"}, None), + ) + tool.Blender.get_default_selection_keypmap() + ( ("bim.annotation_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}), ("bim.annotation_hotkey", {"type": "C", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_C")]}), ("bim.annotation_hotkey", {"type": "E", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_E")]}), @@ -221,12 +225,29 @@ class AnnotationToolUI: props = tool.Drawing.get_document_props() row.prop(props, "should_draw_decorations", text="Viewport Annotations") + _DIMENSION_TYPES = frozenset(("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL")) + @classmethod def draw_edit_object_interface(cls, context): obj = bpy.context.active_object if tool.Ifc.get_entity(obj) and DecoratorData.get_text_data(obj): add_layout_hotkey_operator(cls.layout, "Edit Text", "S_E", "") + obj = context.active_object + element = tool.Ifc.get_entity(obj) if obj else None + if element and element.is_a("IfcAnnotation"): + ptype = ifcopenshell.util.element.get_predefined_type(element) + if ptype in cls._DIMENSION_TYPES: + cls.layout.separator() + ann_props = tool.Drawing.get_annotation_props() + if ann_props.force_perpendicular_to_face: + row = cls.layout.row(align=True) + row.prop(ann_props, "line_position") + cls.layout.separator() + row = cls.layout.row(align=True) + op = row.operator("bim.regenerate_dimensions", icon="FILE_REFRESH", text="Regenerate") + op.active_only = True + @classmethod def draw_type_selection_interface(cls): # shared by both sidebar and header @@ -249,6 +270,11 @@ class AnnotationToolUI: add_layout_hotkey_operator(cls.layout, "Add", "S_A", "Create a new annotation") + _DIMENSION_TYPES = {"DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL"} + if object_type in _DIMENSION_TYPES: + row = cls.layout.row(align=True) + row.prop(cls.props, "force_perpendicular_to_face") + if object_type in ("ELEVATION", "SECTION"): row = cls.layout.row(align=True) row.prop(cls.props, "is_manual_reference") @@ -335,8 +361,16 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if created_objects: bpy.context.view_layer.objects.active = created_objects[-1] + _PARAMETRIC_DIMENSION_TYPES = frozenset( + ("DIMENSION", "RADIUS", "DIAMETER", "ANGLE", "PLAN_LEVEL", "SECTION_LEVEL") + ) + def hotkey_S_A(self): - if bpy.ops.bim.add_annotation.poll(): + props = tool.Drawing.get_annotation_props() + if props.object_type in self._PARAMETRIC_DIMENSION_TYPES: + if bpy.ops.bim.draw_parametric_dimension.poll(): + bpy.ops.bim.draw_parametric_dimension("INVOKE_DEFAULT") + elif bpy.ops.bim.add_annotation.poll(): bpy.ops.bim.add_annotation("INVOKE_DEFAULT") def hotkey_S_E(self): diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index f8389cb3af..8d075a9605 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -85,7 +85,7 @@ class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator): class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_mesh_separate" bl_label = "IFC Mesh Separate" - blender_op = bpy.ops.mesh.separate.get_rna_type() + blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument] bl_description = blender_op.description + ".\nAlso makes sure changes are in sync with IFC." bl_options = {"REGISTER", "UNDO"} blender_type_prop = blender_op.properties["type"] @@ -246,7 +246,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator): class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_origin_set" - blender_op = bpy.ops.object.origin_set.get_rna_type() + blender_op = bpy.ops.object.origin_set.get_rna_type() # ty: ignore[missing-argument] bl_label = "IFC Origin Set" bl_description = ( blender_op.description + ".\nAlso makes sure changes are in sync with IFC (operator works only on IFC objects)" @@ -801,7 +801,7 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context class OverrideDelete(bpy.types.Operator): bl_idname = "bim.override_object_delete" bl_label = "IFC Delete" - blender_op = bpy.ops.object.delete.get_rna_type() + blender_op = bpy.ops.object.delete.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes in sync with IFC." @@ -821,7 +821,7 @@ class OverrideDelete(bpy.types.Operator): def poll(cls, context): # Match `object.delete` poll for consistency. # `object.delete` poll just checks for OBJECT mode. - poll = bpy.ops.object.delete.poll() + poll = bpy.ops.object.delete.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available in OBJECT mode") @@ -1045,7 +1045,7 @@ class SelectedIdsData(NamedTuple): class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_outliner_delete" bl_label = "IFC Delete" - blender_op = bpy.ops.outliner.delete.get_rna_type() + blender_op = bpy.ops.outliner.delete.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes in sync with IFC." @@ -1060,13 +1060,13 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator): def poll(cls, context) -> bool: # Match `outliner.delete` poll for consistency. # `outliner.delete` just checks `area.type` == `OUTLINER`. - poll = bpy.ops.outliner.delete.poll() + poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available from Outliner.") return False - def execute(self, context): + def execute(self, context): # ty:ignore[override-of-final-method] if len(getattr(context, "selected_ids", [])) == 0: return {"FINISHED"} @@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator): def poll(cls, context) -> bool: # Match `object.duplicate_move` poll for consistency. # `object.duplicate_move` poll checks for OBJECT mode. - poll = bpy.ops.object.duplicate_move.poll() + poll = bpy.ops.object.duplicate_move.poll() # ty: ignore[missing-argument] if poll: return True cls.poll_message_set("Only available in OBJECT mode") @@ -1908,7 +1908,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator): class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.override_object_join" bl_label = "IFC Join" - blender_op = bpy.ops.mesh.separate.get_rna_type() + blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument] bl_description = ( blender_op.description + ".\nAlso makes sure changes are in sync with IFC." @@ -1926,7 +1926,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): - if not bpy.ops.object.join.poll(): + if not bpy.ops.object.join.poll(): # ty: ignore[missing-argument] cls.poll_message_set("Active object is not EDITable.") return False if not context.selected_editable_objects: @@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): elif obj in pprops.clipping_planes_objs: self.report({"ERROR"}, "Clipping planes cannot be edited") elif element: - if not obj.data: + if not obj.data or obj.type not in ("MESH", "CURVE"): self.report({"INFO"}, "No geometry to edit") elif tool.Geometry.is_locked(element): self.report({"ERROR"}, lock_error_message(obj.name)) diff --git a/src/bonsai/bonsai/bim/module/georeference/prop.py b/src/bonsai/bonsai/bim/module/georeference/prop.py index 41035398e9..9b1f8d5f4d 100644 --- a/src/bonsai/bonsai/bim/module/georeference/prop.py +++ b/src/bonsai/bonsai/bim/module/georeference/prop.py @@ -139,7 +139,9 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ tool.Georeference.set_coordinates( "blender", ifcopenshell.util.geolocation.enh2xyz( - *local_coordinates, + local_coordinates[0], + local_coordinates[1], + local_coordinates[2], float(props.blender_offset_x), float(props.blender_offset_y), float(props.blender_offset_z), @@ -162,7 +164,9 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types tool.Georeference.set_coordinates( "blender", ifcopenshell.util.geolocation.enh2xyz( - *local_coordinates, + local_coordinates[0], + local_coordinates[1], + local_coordinates[2], float(props.blender_offset_x), float(props.blender_offset_y), float(props.blender_offset_z), @@ -267,6 +271,8 @@ class BIMGeoreferenceProperties(PropertyGroup): x_axis_ordinate: str x_axis_is_null: bool + model_is_georeferenced: bool + model_crs: str model_origin: str model_origin_si: str model_project_north: str diff --git a/src/bonsai/bonsai/bim/module/gis/prop.py b/src/bonsai/bonsai/bim/module/gis/prop.py index 9f685517c3..5971fa80a9 100644 --- a/src/bonsai/bonsai/bim/module/gis/prop.py +++ b/src/bonsai/bonsai/bim/module/gis/prop.py @@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty class BIMCityJsonProperties(PropertyGroup): def get_lods(self, context): - global LODS_ENUM_ITEMS + global LODS_ENUM_ITEMS # ty: ignore[unresolved-global] LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods] return LODS_ENUM_ITEMS diff --git a/src/bonsai/bonsai/bim/module/group/operator.py b/src/bonsai/bonsai/bim/module/group/operator.py index adea9ee48c..f58ba72763 100644 --- a/src/bonsai/bonsai/bim/module/group/operator.py +++ b/src/bonsai/bonsai/bim/module/group/operator.py @@ -43,11 +43,11 @@ class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Toggle Group" bl_options = {"REGISTER", "UNDO"} - ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + ifc_definition_id: bpy.props.IntProperty() + group_type: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(tool.Group.GroupType)], ) - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)], ) diff --git a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py index df47b7774c..6f4fec0836 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/__init__.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/__init__.py @@ -34,8 +34,10 @@ classes = ( operator.Fetch, operator.Merge, operator.ObjectLog, + operator.SelectConflictEntity, operator.Push, operator.RefreshGit, + operator.RenameBranch, operator.SwitchRevision, operator.InstallGit, operator.RunGitDiff, diff --git a/src/bonsai/bonsai/bim/module/ifcgit/data.py b/src/bonsai/bonsai/bim/module/ifcgit/data.py index 92da517a1c..7dbd26b5e0 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/data.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/data.py @@ -21,65 +21,71 @@ class IfcGitData: @classmethod def load(cls): + repo = None + if bool(tool.Ifc.get()): + path_ifc = tool.Ifc.get_path() + if os.path.isfile(path_ifc): + repo = tool.IfcGit.repo_from_path(path_ifc) + cls.data = { - "repo": cls.repo(), - "remotes": cls.remotes(), - "branch_names": cls.branch_names(), - "remote_names": cls.remote_names(), - "remote_urls": cls.remote_urls(), + "repo": repo, + "remotes": repo.remotes if repo else None, + "branch_names": cls.branch_names(repo), + "tag_names": cls.tag_names(repo), + "remote_names": cls.remote_names(repo), + "remote_urls": {r.name: r.url for r in repo.remotes} if repo else {}, "path_ifc": cls.path_ifc(), "branches_by_hexsha": cls.branches_by_hexsha(), "tags_by_hexsha": cls.tags_by_hexsha(), - "name_ifc": cls.name_ifc(), + "name_ifc": cls.name_ifc(repo), "dir_name": cls.dir_name(), "base_name": cls.base_name(), - "working_dir": cls.working_dir(), - "untracked_files": cls.untracked_files(), - "is_detached": cls.is_detached(), - "active_branch_name": cls.active_branch_name(), - "is_dirty": cls.is_dirty(), - "commit": cls.commit(), - "current_revision": cls.current_revision(), + "working_dir": repo.working_dir if repo else None, + "ifc_is_untracked": cls.ifc_is_untracked(repo), + "is_detached": repo.head.is_detached if repo else None, + "active_branch_name": repo.active_branch.name if repo and not repo.head.is_detached else None, + "is_dirty": cls.is_dirty(repo), + "current_revision": cls.current_revision(repo), "git_exe": cls.git_exe(), "ifcmerge_exe": cls.ifcmerge_exe(), } cls.is_loaded = True @classmethod - def repo(cls): - if bool(tool.Ifc.get()): - path_ifc = tool.Ifc.get_path() - if os.path.isfile(path_ifc): - return tool.IfcGit.repo_from_path(path_ifc) - return None + def branch_names(cls, repo): + if not repo or not repo.heads: + return [] + names = sorted([b.name for b in repo.branches]) + if "main" in names: + names.remove("main") + names = ["main"] + names + if repo.remotes: + for remote in repo.remotes: + for ref in remote.refs: + names.append(ref.name) + return names @classmethod - def remotes(cls): - if cls.repo(): - return cls.repo().remotes - return None + def tag_names(cls, repo): + if not repo: + return [] + return [t.name for t in repo.tags] @classmethod - def branch_names(cls): - return [] - - @classmethod - def remote_names(cls): - return [] - - @classmethod - def remote_urls(cls): - result = {} - if cls.repo(): - for remote in cls.repo().remotes: - result[remote.name] = remote.url - return result + def remote_names(cls, repo): + if not repo: + return [] + names = sorted([r.name for r in repo.remotes]) + if "origin" in names: + names.remove("origin") + names = ["origin"] + names + return names @classmethod def path_ifc(cls): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return tool.Ifc.get_path() + return path_ifc return None @classmethod @@ -88,7 +94,8 @@ class IfcGitData: if tool.IfcGitRepo.repo.branches: return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo) except AttributeError: - return {} + pass + return {} @classmethod def tags_by_hexsha(cls): @@ -97,12 +104,11 @@ class IfcGitData: return {} @classmethod - def name_ifc(cls): - if bool(tool.Ifc.get()): + def name_ifc(cls, repo): + if bool(tool.Ifc.get()) and repo: path_ifc = tool.Ifc.get_path() - if tool.IfcGitRepo.repo and os.path.isfile(path_ifc): - working_dir = tool.IfcGitRepo.repo.working_dir - return os.path.relpath(path_ifc, working_dir) + if os.path.isfile(path_ifc): + return os.path.relpath(path_ifc, repo.working_dir) return None @classmethod @@ -122,49 +128,28 @@ class IfcGitData: return None @classmethod - def working_dir(cls): - if cls.repo(): - return cls.repo().working_dir + def ifc_is_untracked(cls, repo): + """Return True if the IFC file exists in the repo but has not been added to git.""" + if not repo: + return False + path_ifc = tool.Ifc.get_path() + if not os.path.isfile(path_ifc): + return False + return not bool(repo.git.ls_files(path_ifc)) @classmethod - def untracked_files(cls): - if cls.repo(): - return cls.repo().untracked_files - return [] - - @classmethod - def is_detached(cls): - if cls.repo(): - return cls.repo().head.is_detached - - @classmethod - def active_branch_name(cls): - if cls.repo() and not cls.is_detached(): - return cls.repo().active_branch.name - - @classmethod - def is_dirty(cls): - if cls.repo() and cls.git_exe(): + def is_dirty(cls, repo): + if repo and cls.git_exe(): path_ifc = tool.Ifc.get_path() if os.path.isfile(path_ifc): - return cls.repo().is_dirty(path=path_ifc) + return repo.is_dirty(path=path_ifc) return False @classmethod - def commit(cls): + def current_revision(cls, repo): props = tool.IfcGit.get_ifcgit_props() - if cls.repo() and len(props.ifcgit_commits) > 0: - item = props.ifcgit_commits[props.commit_index] - try: - return cls.repo().commit(rev=item.hexsha) - except ValueError: - return - - @classmethod - def current_revision(cls): - props = tool.IfcGit.get_ifcgit_props() - if cls.repo() and cls.repo().head.is_valid() and len(props.ifcgit_commits) > 0: - return tool.IfcGitRepo.repo.commit() + if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0: + return repo.commit() @classmethod def git_exe(cls): diff --git a/src/bonsai/bonsai/bim/module/ifcgit/operator.py b/src/bonsai/bonsai/bim/module/ifcgit/operator.py index 829a0a62a2..65bec6b252 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/operator.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/operator.py @@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator): if props.commit_message == "": return False if repo: - if props.new_branch_name in [branch.name for branch in repo.branches]: + if props.new_branch_name in IfcGitData.data["branch_names"]: cls.poll_message_set("Branch already exists!") return False elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name): - if repo.head.is_detached: + if IfcGitData.data["is_detached"]: cls.poll_message_set("Branch name is invalid or empty!") return False elif props.new_branch_name != "": @@ -134,10 +134,17 @@ class CommitChanges(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.commit_changes(tool.IfcGit, tool.Ifc, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + commit_message = props.commit_message + new_branch_name = props.new_branch_name + core.commit_changes(tool.IfcGit, tool.Ifc, commit_message, new_branch_name) + props.new_branch_name = "" + props.commit_message = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() + IfcGitData.load() + if new_branch_name: + props.display_branch = new_branch_name return {"FINISHED"} @@ -157,7 +164,7 @@ class AddTag(bpy.types.Operator): repo = IfcGitData.data["repo"] if repo and ( not tool.IfcGit.is_valid_ref_format(props.new_tag_name) - or props.new_tag_name in [tag.name for tag in repo.tags] + or props.new_tag_name in IfcGitData.data["tag_names"] ): return False return True @@ -165,8 +172,12 @@ class AddTag(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_tag(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + item = props.ifcgit_commits[props.commit_index] + core.add_tag(tool.IfcGit, repo, item.hexsha, props.new_tag_name, props.new_tag_message) + props.new_tag_name = "" + props.new_tag_message = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -183,7 +194,7 @@ class DeleteTag(bpy.types.Operator): repo = IfcGitData.data["repo"] core.delete_tag(tool.IfcGit, repo, self.tag_name) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -191,7 +202,7 @@ class DeleteTag(bpy.types.Operator): class RefreshGit(bpy.types.Operator): """Refresh revision list""" - bl_label = "" + bl_label = "Refresh" bl_idname = "ifcgit.refresh" bl_options = {"REGISTER"} @@ -205,8 +216,7 @@ class RefreshGit(bpy.types.Operator): def execute(self, context): - repo = IfcGitData.data["repo"] - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() tool.IfcGit.decolourise() return {"FINISHED"} @@ -215,7 +225,7 @@ class RefreshGit(bpy.types.Operator): class DisplayRevision(bpy.types.Operator): """Colourise objects by selected revision""" - bl_label = "" + bl_label = "Colourise Revision" bl_idname = "ifcgit.display_revision" bl_options = {"REGISTER"} @@ -250,7 +260,7 @@ class DisplayUncommitted(bpy.types.Operator): class SwitchRevision(bpy.types.Operator): """Switches the repository to the selected revision and reloads the IFC file""" - bl_label = "" + bl_label = "Switch Revision" bl_idname = "ifcgit.switch_revision" bl_options = {"REGISTER"} @@ -268,7 +278,7 @@ class SwitchRevision(bpy.types.Operator): class Merge(bpy.types.Operator): - """Merges the selected branch into working branch""" + """Merges the selected branch into working branch.\nCtrl+click to preview without merging""" bl_label = "Merge this branch" bl_idname = "ifcgit.merge" @@ -282,15 +292,84 @@ class Merge(bpy.types.Operator): return True return False - def execute(self, context): + def invoke(self, context, event): + if event.ctrl: + core.dry_run_merge(tool.IfcGit, tool.Ifc, self) + refresh() + return {"FINISHED"} + return self.execute(context) - if core.merge_branch(tool.IfcGit, tool.Ifc, self): + def execute(self, context): + if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False: refresh() return {"FINISHED"} else: return {"CANCELLED"} +class SelectConflictEntity(bpy.types.Operator): + """Select the conflicting entity in the viewport""" + + bl_label = "Select Conflict Entity" + bl_idname = "ifcgit.select_conflict_entity" + bl_options = {"REGISTER"} + + step_id: bpy.props.IntProperty() + + if TYPE_CHECKING: + step_id: int + + def execute(self, context): + model = tool.Ifc.get() + if not model: + return {"CANCELLED"} + + try: + entity = model.by_id(self.step_id) + except Exception: + self.report({"WARNING"}, f"Entity #{self.step_id} not found (may have been deleted locally)") + return {"CANCELLED"} + + obj = tool.Ifc.get_object(entity) + if obj is None: + # Walk inverse references up to 5 hops to find nearest entity with a Blender object + visited = {entity.id()} + queue = [entity] + for _ in range(5): + next_queue = [] + for ent in queue: + for inv in model.get_inverse(ent): + if inv.id() in visited: + continue + visited.add(inv.id()) + obj = tool.Ifc.get_object(inv) + if obj is not None: + break + next_queue.append(inv) + if obj is not None: + break + if obj is not None: + break + queue = next_queue + + if obj is None: + self.report({"INFO"}, f"No viewport representation found for #{self.step_id} ({entity.is_a()})") + return {"CANCELLED"} + + bpy.ops.object.select_all(action="DESELECT") + obj.select_set(True) + context.view_layer.objects.active = obj + for area in context.screen.areas: + if area.type == "VIEW_3D": + region = next((r for r in area.regions if r.type == "WINDOW"), None) + if region: + with context.temp_override(area=area, region=region): + bpy.ops.view3d.view_selected() + break + + return {"FINISHED"} + + class Push(bpy.types.Operator): """Pushes the working branch to selected remote""" @@ -314,9 +393,9 @@ class Fetch(bpy.types.Operator): def execute(self, context): props = tool.IfcGit.get_ifcgit_props() - repo = IfcGitData.data["repo"] - remote = repo.remotes[props.select_remote] - remote.fetch() + core.fetch(tool.IfcGit, props.select_remote) + core.refresh_revision_list(tool.IfcGit, tool.Ifc) + refresh() return {"FINISHED"} @@ -336,7 +415,7 @@ class AddRemote(bpy.types.Operator): not repo or not tool.IfcGit.is_valid_ref_format(props.remote_name) or not props.remote_url - or props.remote_name in [remote.name for remote in repo.remotes] + or props.remote_name in IfcGitData.data["remote_names"] ): return False return True @@ -344,8 +423,11 @@ class AddRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.add_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + core.add_remote(tool.IfcGit, repo, props.remote_name, props.remote_url) + props.remote_name = "" + props.remote_url = "" + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -360,8 +442,19 @@ class DeleteRemote(bpy.types.Operator): def execute(self, context): repo = IfcGitData.data["repo"] - core.delete_remote(tool.IfcGit, repo) - core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc) + props = tool.IfcGit.get_ifcgit_props() + remote_name = props.select_remote + if props.display_branch.startswith(remote_name + "/"): + active = IfcGitData.data["active_branch_name"] + if active: + props.display_branch = active + else: + local_branches = [b for b in IfcGitData.data["branch_names"] if "/" not in b] + if local_branches: + props.display_branch = local_branches[0] + core.delete_remote(tool.IfcGit, repo, remote_name) + tool.IfcGit.select_first_remote() + core.refresh_revision_list(tool.IfcGit, tool.Ifc) refresh() return {"FINISHED"} @@ -375,8 +468,8 @@ class ObjectLog(bpy.types.Operator): @classmethod def poll(cls, context): - if not (obj := context.active_object): - cls.poll_message_set("No Active Object") + if not (obj := context.active_object) or not obj.select_get(): + cls.poll_message_set("No selected object") elif not tool.Blender.get_ifc_definition_id(obj): cls.poll_message_set("Active Object doesn't have an IFC definition") else: @@ -422,7 +515,7 @@ class RunGitDiff(bpy.types.Operator): ) bl_options = set() - save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: save_to_temp: bool @@ -445,3 +538,37 @@ class RunGitDiff(bpy.types.Operator): def execute(self, context): core.run_git_diff(tool.IfcGit, self, self.save_to_temp) return {"FINISHED"} + + +class RenameBranch(bpy.types.Operator): + """Rename the current branch""" + + bl_label = "Rename Branch" + bl_idname = "ifcgit.rename_branch" + bl_options = {"REGISTER"} + + new_name: bpy.props.StringProperty(name="New name") + + if TYPE_CHECKING: + new_name: str + + @classmethod + def poll(cls, context): + IfcGitData.make_sure_is_loaded() + if not IfcGitData.data["repo"]: + return False + if IfcGitData.data["is_detached"]: + return False + if IfcGitData.data["is_dirty"]: + return False + return True + + def invoke(self, context, event): + self.new_name = IfcGitData.data["active_branch_name"] + return context.window_manager.invoke_props_dialog(self) + + def execute(self, context): + repo = IfcGitData.data["repo"] + core.rename_branch(tool.IfcGit, repo, self.new_name) + refresh() + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/ifcgit/prop.py b/src/bonsai/bonsai/bim/module/ifcgit/prop.py index cba3ca632c..494da26f35 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/prop.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/prop.py @@ -17,28 +17,14 @@ from bonsai.bim.module.ifcgit.data import IfcGitData def git_branches(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: # NOTE "Python must keep a reference to the strings returned by # the callback or Blender will misbehave or even crash" - IfcGitData.data["branch_names"] = sorted([branch.name for branch in IfcGitData.data["repo"].heads]) - - if "main" in IfcGitData.data["branch_names"]: - IfcGitData.data["branch_names"].remove("main") - IfcGitData.data["branch_names"] = ["main"] + IfcGitData.data["branch_names"] - - if IfcGitData.data["remotes"]: - for remote in IfcGitData.data["remotes"]: - for remote_branch in remote.refs: - IfcGitData.data["branch_names"].append(remote_branch.name) - - return [(myname, myname, myname) for myname in IfcGitData.data["branch_names"]] + # Branch list (local + remote, main first) is computed once in IfcGitData.load() + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["branch_names"]] def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: - IfcGitData.data["remote_names"] = sorted([remote.name for remote in IfcGitData.data["remotes"]]) - - if "origin" in IfcGitData.data["remote_names"]: - IfcGitData.data["remote_names"].remove("origin") - IfcGitData.data["remote_names"] = ["origin"] + IfcGitData.data["remote_names"] - - return [(myname, myname, myname) for myname in IfcGitData.data["remote_names"]] + IfcGitData.make_sure_is_loaded() + return [(name, name, name) for name in IfcGitData.data["remote_names"]] def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None: @@ -90,6 +76,7 @@ class IfcGitListItem(PropertyGroup): name="Commit Message", default="", ) + committed_date: IntProperty(name="Committed Date", default=0) tags: CollectionProperty(type=IfcGitTag, name="List of revision tags") if TYPE_CHECKING: @@ -98,6 +85,7 @@ class IfcGitListItem(PropertyGroup): author_name: str author_email: str message: str + committed_date: int tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag] @@ -151,6 +139,11 @@ class IfcGitProperties(PropertyGroup): ], update=update_revlist, ) + merge_conflicts: StringProperty( + name="Merge Conflicts", + description="JSON report from last failed merge attempt", + default="", + ) if TYPE_CHECKING: ifcgit_commits: bpy.types.bpy_prop_collection_idprop[IfcGitListItem] @@ -165,3 +158,4 @@ class IfcGitProperties(PropertyGroup): display_branch: str select_remote: str ifcgit_filter: Literal["all", "tagged", "relevant"] + merge_conflicts: str diff --git a/src/bonsai/bonsai/bim/module/ifcgit/ui.py b/src/bonsai/bonsai/bim/module/ifcgit/ui.py index d8b7357e7b..901dee31ad 100644 --- a/src/bonsai/bonsai/bim/module/ifcgit/ui.py +++ b/src/bonsai/bonsai/bim/module/ifcgit/ui.py @@ -52,7 +52,7 @@ class IFCGIT_PT_panel(bpy.types.Panel): if IfcGitData.data["repo"] and os.path.exists(IfcGitData.data["repo"].git_dir): name_ifc = IfcGitData.data["name_ifc"] row.label(text=IfcGitData.data["working_dir"], icon="SYSTEM") - if name_ifc in IfcGitData.data["untracked_files"]: + if IfcGitData.data["ifc_is_untracked"]: row.operator( "ifcgit.addfile", text="Add '" + name_ifc + "' to repository", @@ -112,15 +112,13 @@ class IFCGIT_PT_panel(bpy.types.Panel): row.label(text="Working branch: Detached HEAD") else: row.label(text="Working branch: " + IfcGitData.data["active_branch_name"]) + row.operator("ifcgit.rename_branch", icon="GREASEPENCIL", text="") - grouped = layout.row() - column = grouped.column() - row = column.row() + row = layout.row() row.prop(props, "display_branch", text="Browse branch") row.prop(props, "ifcgit_filter", text="Filter revisions") - row = column.row() - row.template_list( + layout.template_list( "COMMIT_UL_List", "The_List", props, @@ -128,20 +126,64 @@ class IFCGIT_PT_panel(bpy.types.Panel): props, "commit_index", ) - column = grouped.column() - row = column.row() + + row = layout.row(align=True) row.operator("ifcgit.refresh", icon="FILE_REFRESH") - if not is_dirty: - - row = column.row() row.operator("ifcgit.display_revision", icon="SELECT_DIFFERENCE") - - row = column.row() row.operator("ifcgit.switch_revision", icon="CURRENT_FILE") + row.operator("ifcgit.merge", icon="SYSTEM") - row = column.row() - row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="") + conflicts = tool.IfcGit.get_merge_conflicts() + if conflicts is not None: + box = layout.box() + box.alert = True + row = box.row() + row.label( + text=f"Merge failed \u2014 {len(conflicts)} conflict(s)", + icon="ERROR", + ) + for conflict in conflicts: + col = box.column(align=True) + conflict_type = conflict.get("type", "") + entity_id = conflict.get("entity_id", "?") + local_id = conflict.get("original_local_id") + + if conflict_type == "attribute_conflict": + entity_class = conflict.get("entity_class", "Entity") + attr_idx = conflict.get("attribute_index", "?") + desc = f"#{entity_id} {entity_class}: attribute {attr_idx} conflict" + elif conflict_type == "entity_deleted_and_modified": + entity_class = conflict.get("entity_class", "Entity") + desc = f"#{entity_id} {entity_class}: " + conflict.get("message", "deleted/modified conflict") + elif conflict_type == "class_changed": + desc = ( + f"#{entity_id}: class changed " + + conflict.get("base_class", "?") + + " \u2192 " + + conflict.get("modified_class", "?") + ) + elif conflict_type == "required_entity_deleted": + desc = f"#{entity_id}: " + conflict.get("message", "required entity deleted") + else: + desc = f"#{entity_id}: {conflict_type}" + + row = col.row(align=True) + row.label(text=desc) + if local_id: + op = row.operator( + "ifcgit.select_conflict_entity", + text="", + icon="RESTRICT_SELECT_OFF", + ) + op.step_id = local_id + + if conflict_type == "attribute_conflict": + sub = col.column(align=True) + sub.scale_y = 0.75 + sub.label(text=f" Base: {conflict.get('base_value', '')}") + sub.label(text=f" Local: {conflict.get('local_value', '')}") + sub.label(text=f" Remote: {conflict.get('remote_value', '')}") if not props.ifcgit_commits: return @@ -216,13 +258,7 @@ class COMMIT_UL_List(bpy.types.UIList): ): current_revision = IfcGitData.data["current_revision"] - - # TODO Figure how this "item" can be acesse in "data.py" - # so it's possible to move the ".commit" - try: - commit = IfcGitData.data["repo"].commit(rev=item.hexsha) - except ValueError: - return + current_hexsha = current_revision.hexsha if current_revision else None lookup = IfcGitData.data["branches_by_hexsha"] refs = "" @@ -236,11 +272,11 @@ class COMMIT_UL_List(bpy.types.UIList): for tag in lookup[item.hexsha]: refs += "{" + tag.name + "} " - if commit == current_revision: - layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME") + if item.hexsha == current_hexsha: + layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME") else: - layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE") - layout.label(text=time.strftime("%c", time.localtime(commit.committed_date))) + layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE") + layout.label(text=time.strftime("%c", time.localtime(item.committed_date))) def draw_filter(self, context, layout): diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 6ee37f2285..c3f377e9b9 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -272,21 +272,21 @@ class RadianceRender(bpy.types.Operator): + '''" map_u map_v 0 1 0.5 - + # This is a multiplier to colour balance the env map # In this case, it provides a rough ground luminance from 3k-5k env_map colorfunc env_colour 4 100 100 100 . 0 0 - + # .37 .57 1.5 is measured from a HDRI image # It is multiplied by a factor such that grey(r,g,b) = 1 skyfunc colorfunc sky_colour 4 .64 .99 2.6 . 0 0 - + void mixpict composite 7 env_colour sky_colour grey "''' + hdr_mask_path @@ -295,22 +295,22 @@ void mixpict composite + """" map_u map_v 0 2 0.5 1 - + composite glow env_map_glow 0 0 4 1 1 1 0 - + env_map_glow source sky 0 0 4 0 0 1 180 - + env_colour glow ground_glow 0 0 4 1 1 1 0 - + ground_glow source ground 0 0 @@ -566,7 +566,7 @@ class LightPickCoordinates(bpy.types.Operator): ) bl_options = {"REGISTER", "UNDO"} - use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: use_current_location: bool diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index d3498f18b8..24233b44d8 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -320,7 +320,7 @@ class RadianceExporterProperties(PropertyGroup): ) def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: - global SUBCATEGORIES_ENUM_ITEMS + global SUBCATEGORIES_ENUM_ITEMS # ty: ignore[unresolved-global] if self.category in spectraldb: SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()] else: diff --git a/src/bonsai/bonsai/bim/module/material/operator.py b/src/bonsai/bonsai/bim/module/material/operator.py index 08cddbb928..a63dd1bf2b 100644 --- a/src/bonsai/bonsai/bim/module/material/operator.py +++ b/src/bonsai/bonsai/bim/module/material/operator.py @@ -630,7 +630,7 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator): slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set) if material_set_usage.is_a("IfcMaterialProfileSetUsage"): - if "CardinalPoint" in attributes: + if "CardinalPoint" in attributes and attributes["CardinalPoint"] is not None: attributes["CardinalPoint"] = int(attributes["CardinalPoint"]) ifcopenshell.api.material.edit_profile_usage( self.file, @@ -717,7 +717,11 @@ class EnableEditingMaterialSetItem(bpy.types.Operator): self.props.material_set_item_material = str(material_set_item.Material.id()) self.props.material_set_item_attributes.clear() - bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes) + bonsai.bim.helper.import_attributes( + material_set_item, + self.props.material_set_item_attributes, + callback=self.import_attributes_callback, + ) if material_set_item.is_a("IfcMaterialProfile"): if material_set_item.Profile and material_set_item.Profile.ProfileName: @@ -725,6 +729,29 @@ class EnableEditingMaterialSetItem(bpy.types.Operator): return {"FINISHED"} + def import_attributes_callback( + self, name: str, prop: Union["Attribute", None], data: dict[str, Any] + ) -> None | Literal[True]: + if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop: + return None + + # Keep null semantics unchanged on export, but avoid an empty UI selection. + prop.data_type = "enum" + prop.special_type = "LOGICAL" + prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN")) + + value = data[name] + if value == "UNKNOWN": + prop.enum_value = "UNKNOWN" + elif value is None: + # Keep visible default as FALSE, but preserve null semantics on save. + prop.enum_value = "FALSE" + prop.is_null = True + else: + prop.enum_value = "TRUE" if value else "FALSE" + + return True + class DisableEditingMaterialSetItem(bpy.types.Operator): bl_idname = "bim.disable_editing_material_set_item" @@ -777,6 +804,8 @@ class EditMaterialSetItem(bpy.types.Operator, tool.Ifc.Operator): ) slab.DumbSlabPlaner().regenerate_from_layer(layer) wall.DumbWallPlaner().regenerate_from_layer(layer) + from bonsai.bim.module.drawing.handler import regenerate_dims_for_layer + regenerate_dims_for_layer(self.file, layer) elif material.is_a("IfcMaterialProfileSet"): profile_def = None if mprops.profiles: diff --git a/src/bonsai/bonsai/bim/module/misc/operator.py b/src/bonsai/bonsai/bim/module/misc/operator.py index 33da9a05dc..203bc9dd6c 100644 --- a/src/bonsai/bonsai/bim/module/misc/operator.py +++ b/src/bonsai/bonsai/bim/module/misc/operator.py @@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator): "Will unassign element from a type if type has a representation." ) bl_options = {"REGISTER", "UNDO"} - mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + mode: bpy.props.EnumProperty( default="BOOLEAN", items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)), ) @@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator): bl_idname = "bim.confirm_quick_favorite_operator" bl_label = "Confirm Operator" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: index: int @@ -452,10 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator): bl_idname = "bim.move_quick_favorites_item" bl_label = "Move Quick Favorites Item" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] - items=[("UP", "Up", ""), ("DOWN", "Down", "")] - ) + index: bpy.props.IntProperty() + direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")]) if TYPE_CHECKING: index: int @@ -474,7 +472,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator): bl_idname = "bim.remove_quick_favorites_item" bl_label = "Remove Quick Favorites Item" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: index: int diff --git a/src/bonsai/bonsai/bim/module/misc/prop.py b/src/bonsai/bonsai/bim/module/misc/prop.py index ddeda73b88..74a82f06cd 100644 --- a/src/bonsai/bonsai/bim/module/misc/prop.py +++ b/src/bonsai/bonsai/bim/module/misc/prop.py @@ -36,9 +36,9 @@ QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "stri class QuickFavoriteEnumItem(PropertyGroup): - name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration] - display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration] - description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration] + name: StringProperty(name="Name", default="") + display_name: StringProperty(name="Display Name", default="") + description: StringProperty(name="Description", default="") if TYPE_CHECKING: name: str @@ -51,19 +51,19 @@ def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | N class QuickFavoriteProperty(PropertyGroup): - name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration] - display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration] - value_prop: EnumProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty(name="Name", default="") + display_name: StringProperty(name="Display Name", default="") + value_prop: EnumProperty( name="Value Prop", items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)), ) - string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration] - float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration] - int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration] - bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration] - enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration] - enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration] - is_active: BoolProperty( # pyright: ignore[reportRedeclaration] + string_value: StringProperty(name="String Value", default="") + float_value: FloatProperty(name="Float Value", default=0.0) + int_value: IntProperty(name="Int Value", default=0) + bool_value: BoolProperty(name="Bool Value", default=False) + enum_value: EnumProperty(name="Enum Value", items=get_enum_items) + enum_items: CollectionProperty(type=QuickFavoriteEnumItem) + is_active: BoolProperty( name="Is Active", description="Only active properties will be added to the operator when invoked from Quick Favorites", default=False, @@ -100,20 +100,20 @@ def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Cont class QuickFavoritesItem(PropertyGroup): - is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration] - search: StringProperty( # pyright: ignore[reportRedeclaration] + is_expanded: BoolProperty(name="Is Expanded", default=False) + search: StringProperty( name="Search", default="", search=get_operator_suggestions, # Resetting `search_options`, allowing users only to use suggestions. search_options=set(), ) - properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration] - operator_id: StringProperty( # pyright: ignore[reportRedeclaration] + properties: CollectionProperty(type=QuickFavoriteProperty) + operator_id: StringProperty( name="Operator ID", default="", ) - label: StringProperty( # pyright: ignore[reportRedeclaration] + label: StringProperty( name="Label", description="Label that will be used in Quick Favorites for this operator", default="", @@ -139,15 +139,15 @@ class QuickFavoritesItem(PropertyGroup): class BIMMiscProperties(PropertyGroup): - total_storeys: IntProperty( # pyright: ignore[reportRedeclaration] + total_storeys: IntProperty( name="Total Storeys", description="Number of storeys above object's storey to take into account for resizing", default=1, ) - override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration] + override_colour: FloatVectorProperty( name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4 ) - quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration] + quick_favorites: CollectionProperty(type=QuickFavoritesItem) if TYPE_CHECKING: total_storeys: int diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 49090e2c9f..950ca65199 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -753,6 +753,8 @@ class PolylineDecorator: rv3d = region.data polyline_props = tool.Model.get_polyline_props() + if not polyline_props.snap_mouse_point: + return snap_prop = polyline_props.snap_mouse_point[0] mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) @@ -820,6 +822,8 @@ class PolylineDecorator: gpu.state.point_size_set(6) polyline_props = tool.Model.get_polyline_props() + if not polyline_props.snap_mouse_point: + return snap_prop = polyline_props.snap_mouse_point[0] # Point related to the mouse mouse_point = [Vector((snap_prop.x, snap_prop.y, snap_prop.z))] diff --git a/src/bonsai/bonsai/bim/module/model/mep.py b/src/bonsai/bonsai/bim/module/model/mep.py index 245e10a21b..df34166229 100644 --- a/src/bonsai/bonsai/bim/module/model/mep.py +++ b/src/bonsai/bonsai/bim/module/model/mep.py @@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator): is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001) is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001) - if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23): + if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]): fitting_type = "WYE" if not fitting_type: @@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0) end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0) radius: bpy.props.FloatProperty( - "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 + name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 ) def _execute(self, context): diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py index c20325c2e7..1c157afe4e 100644 --- a/src/bonsai/bonsai/bim/module/model/opening.py +++ b/src/bonsai/bonsai/bim/module/model/opening.py @@ -151,29 +151,11 @@ class FilledOpeningGenerator: existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) assert representation - - # Check if mapped representation - PRESERVE the mapping structure - if ( - representation.RepresentationType == "MappedRepresentation" - and len(representation.Items) == 1 - and representation.Items[0].is_a("IfcMappedItem") - ): - # Store the existing RepresentationMap to reuse it - existing_mapping_source = representation.Items[0].MappingSource - reuse_mapped_representation = True - else: - representation = ifcopenshell.util.representation.resolve_representation(representation) - - if not reuse_mapped_representation: - # Check for library template before generating from filling - template_rep = self.get_opening_template_from_type(filling) - - if template_rep: - representation = template_rep - else: - representation = self.generate_opening_from_filling( - filling, filling_obj, opening_thickness_si=opening_thickness_si - ) + representation = ifcopenshell.util.representation.resolve_representation(representation) + else: + representation = self.generate_opening_from_filling( + filling, filling_obj, opening_thickness_si=opening_thickness_si + ) # Create mapped representation if reuse_mapped_representation: @@ -247,109 +229,38 @@ class FilledOpeningGenerator: voided_element = opening.VoidsElements[0].RelatingBuildingElement opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW") - - # ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.) - preserved_representation = None - if opening_rep: - if ( - opening_rep.RepresentationType == "MappedRepresentation" - and len(opening_rep.Items) == 1 - and opening_rep.Items[0].is_a("IfcMappedItem") - ): - # For mapped representations, copy the underlying representation - preserved_representation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - opening_rep.Items[0].MappingSource.MappedRepresentation, - exclude=["IfcGeometricRepresentationContext"], - ) - else: - # For direct representations (non-mapped), copy them too - preserved_representation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), opening_rep, exclude=["IfcGeometricRepresentationContext"] - ) - ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep) ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep) existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling) - # Priority order for choosing representation: - # 1. Existing occurrence with MappedRepresentation (preserve mapping!) - # 2. Library template with Tessellation - # 3. Preserved representation from old opening (maintain user's work) - # 4. Generate from filling (last resort) - - representation_to_use = None - reuse_mapped_representation = False - existing_mapping_source = None - if existing_opening_occurrence: representation = ifcopenshell.util.representation.get_representation( existing_opening_occurrence, "Model", "Body", "MODEL_VIEW" ) - - if ( - representation - and representation.RepresentationType == "MappedRepresentation" - and len(representation.Items) == 1 - and representation.Items[0].is_a("IfcMappedItem") - ): - # PRESERVE the mapped structure - reuse the same RepresentationMap - existing_mapping_source = representation.Items[0].MappingSource - reuse_mapped_representation = True - else: - representation_to_use = ifcopenshell.util.representation.resolve_representation(representation) - - if not representation_to_use and not reuse_mapped_representation: - template_rep = self.get_opening_template_from_type(filling) - if template_rep and template_rep.RepresentationType == "Tessellation": - representation_to_use = template_rep - - if not representation_to_use and not reuse_mapped_representation and preserved_representation: - representation_to_use = preserved_representation - - if not representation_to_use and not reuse_mapped_representation: + representation = ifcopenshell.util.representation.resolve_representation(representation) + mapped_representation = ifcopenshell.api.geometry.map_representation( + tool.Ifc.get(), representation=representation + ) + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=opening, representation=mapped_representation + ) + else: opening_obj = tool.Ifc.get_object(opening) if opening_obj: tool.Ifc.unlink(element=opening) tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True) filling_obj = tool.Ifc.get_object(filling) - representation_to_use = self.generate_opening_from_filling(filling, filling_obj) - - # Create the mapped representation - if reuse_mapped_representation: - # Reuse existing RepresentationMap - don't create a new one! - context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW") - new_mapped_item = tool.Ifc.get().create_entity( - "IfcMappedItem", - MappingSource=existing_mapping_source, - MappingTarget=tool.Ifc.get().create_entity( - "IfcCartesianTransformationOperator3D", - Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)), - Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)), - LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)), - Scale=1.0, - Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)), - ), - ) - mapped_representation = tool.Ifc.get().create_entity( - "IfcShapeRepresentation", - ContextOfItems=context, - RepresentationIdentifier="Body", - RepresentationType="MappedRepresentation", - Items=[new_mapped_item], - ) - else: + representation = self.generate_opening_from_filling(filling, filling_obj) mapped_representation = ifcopenshell.api.geometry.map_representation( - tool.Ifc.get(), representation=representation_to_use + tool.Ifc.get(), representation=representation + ) + ifcopenshell.api.geometry.assign_representation( + tool.Ifc.get(), product=opening, representation=mapped_representation ) - ifcopenshell.api.geometry.assign_representation( - tool.Ifc.get(), product=opening, representation=mapped_representation - ) - - # update voided object representation... + # update voided object representation or all it's parts if it's an aggregate voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element] for voided_element in voided_elements: voided_obj = tool.Ifc.get_object(voided_element) @@ -363,36 +274,6 @@ class FilledOpeningGenerator: representation=representation, ) - def get_opening_template_from_type( - self, filling: ifcopenshell.entity_instance - ) -> Union[ifcopenshell.entity_instance, None]: - """ - Check if the filling's type has a stored opening template from library import. - """ - element_type = ifcopenshell.util.element.get_type(filling) - - if not element_type: - return None - - desc = element_type.Description - - if not desc or "||BonsaiOpeningTemplate:" not in desc: - return None - - # Extract template ID - marker = desc.split("||BonsaiOpeningTemplate:")[-1] - template_id = int(marker.split("||")[0]) - - try: - template_rep = tool.Ifc.get().by_id(template_id) - # Make a copy so we don't reuse the same representation instance - copied = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), template_rep, exclude=["IfcGeometricRepresentationContext"] - ) - return copied - except: - return None - def generate_opening_from_filling( self, filling: ifcopenshell.entity_instance, @@ -659,6 +540,16 @@ class AddBoolean(Operator, tool.Ifc.Operator): booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator) rep_obj = tool.Geometry.get_geometry_props().representation_obj + if booleans: + # Users typically select two top-level items and expect the + # operand to be absorbed into the boolean, not remain as a + # standalone item alongside it. + representation = tool.Geometry.get_active_representation(rep_obj) + representation = ifcopenshell.util.representation.resolve_representation(representation) + second_items_set = set(second_items) + new_items = [i for i in representation.Items if i not in second_items_set] + if new_items: + representation.Items = new_items rep_element = tool.Ifc.get_entity(rep_obj) tool.Model.mark_manual_booleans(rep_element, booleans) tool.Geometry.reload_representation(rep_obj) diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index e33fe2cf5d..946d0a1c58 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -421,7 +421,7 @@ class PolylineOperator: tool.Polyline.calculate_x_y_and_z(context, self.input_ui, self.tool_state) tool.Blender.update_viewport() - return {"RUNNING_MODAL"} + return {"RUNNING_MODAL"} def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: props = tool.Model.get_model_props() @@ -461,13 +461,26 @@ class PolylineOperator: self.tool_state.axis_method = None self.tool_state.plane_method = None self.tool_state.mode = "Mouse" + # Do not call clear_snap_objs() here — create_snap_obj() validates stale + # entries per-object (vertex count + position check), so the BVH cache can + # safely persist across invocations. Clearing it caused an 11-second stall + # on every Shift+A because SnapObj rebuilds a pure-Python BVH tree. self.visible_objs = tool.Raycast.get_visible_objects(context) for obj in self.visible_objs: if bbox_2d := tool.Raycast.get_on_screen_2d_bounding_boxes(context, obj): self.objs_2d_bbox.append(bbox_2d) - detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) - self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + self._init_snapping_points(context, event) tool.Polyline.calculate_distance_and_angle(context, self.input_ui, self.tool_state) tool.Blender.update_viewport() context.window_manager.modal_handler_add(self) + + def _init_snapping_points(self, context: bpy.types.Context, event: bpy.types.Event) -> None: + """Populate self.snapping_points at operator start. + + Override in subclasses to skip the full BVH snap detection when a cheap + placeholder is sufficient. The default runs the full detection pass. + """ + detected_snaps = tool.Snap.detect_snapping_points(context, event, self.objs_2d_bbox, self.tool_state) + self.snapping_points = tool.Snap.select_snapping_points(context, event, self.tool_state, detected_snaps) + diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index d7c96bce1d..75f5441827 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -545,7 +545,7 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_type_page" bl_label = "Change Type Page" bl_options = {"REGISTER"} - page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + page: bpy.props.IntProperty() if TYPE_CHECKING: page: int @@ -694,10 +694,14 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[ new_settings = settings.copy() new_settings["context"] = box_context - new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings) + new_box = ifcopenshell.api.geometry.add_representation( + ifc_file, + should_run_listeners=False, # ty:ignore[unknown-argument] + **new_settings, + ) ifcopenshell.api.geometry.assign_representation( ifc_file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] product=product, representation=new_box, ) diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index f759d40a94..e5c0991e8e 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -18,7 +18,7 @@ import copy from math import atan2, degrees, pi, radians -from typing import Any, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Literal, Optional, Union import bpy import ifcopenshell @@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None] class DumbProfileGenerator: - def __init__(self, relating_type): + def __init__(self, relating_type: ifcopenshell.entity_instance): self.relating_type = relating_type self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) @@ -201,7 +201,7 @@ class DumbProfileGenerator: class DumbProfileRegenerator: - def regenerate_from_profile_def(self, profile): + def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None: self.file = tool.Ifc.get() objs = [] if not profile: @@ -221,7 +221,7 @@ class DumbProfileRegenerator: for element in self.get_element_types_using_profile(profile): tool.Model.mark_thumbnail_for_update(element) - def regenerate_from_profile(self, usecase_path, ifc_file, settings): + def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None: self.file = ifc_file objs = [] profile = settings["profile"].Profile @@ -233,7 +233,7 @@ class DumbProfileRegenerator: objs.append(obj) DumbProfileRecalculator().recalculate(objs) - def get_elements_using_profile(self, profile): + def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: results = [] profile_sets = [ mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile") @@ -252,7 +252,9 @@ class DumbProfileRegenerator: results.extend(rel.RelatedObjects) return results - def get_element_types_using_profile(self, profile): + def get_element_types_using_profile( + self, profile: ifcopenshell.entity_instance + ) -> list[ifcopenshell.entity_instance]: results = [] profile_sets = [ mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile") @@ -269,12 +271,18 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.extend_profile" bl_label = "Extend Profile" bl_options = {"REGISTER", "UNDO"} - join_type: bpy.props.StringProperty() + join_type: bpy.props.EnumProperty( + items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")], + default="-", + ) + + if TYPE_CHECKING: + join_type: Literal["-", "L", "V", "T"] def _execute(self, context): selected_objs = context.selected_objects joiner = DumbProfileJoiner() - if not self.join_type: + if self.join_type == "-": for obj in selected_objs: joiner.unjoin(obj) return {"FINISHED"} @@ -626,11 +634,15 @@ class DumbProfileJoiner: if connection1 == "ATEND": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[1] = intersect else: plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) @@ -673,11 +685,15 @@ class DumbProfileJoiner: elif connection1 == "ATSTART": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[0] = intersect else: plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) @@ -721,7 +737,9 @@ class DumbProfileJoiner: if connection1 == "ATEND": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[1] = intersect else: plane = self.get_profile_plane( @@ -729,7 +747,9 @@ class DumbProfileJoiner: furthest_plane if is_relating else closest_plane, z_inwards=False if is_relating else True, ) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) self.clippings.append( @@ -742,7 +762,9 @@ class DumbProfileJoiner: elif connection1 == "ATSTART": if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal: plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) self.body[0] = intersect else: plane = self.get_profile_plane( @@ -750,7 +772,9 @@ class DumbProfileJoiner: furthest_plane if is_relating else closest_plane, z_inwards=False if is_relating else True, ) - intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d()) + intersect = mathutils.geometry.intersect_line_plane( + axis1[0], axis1[1], plane.translation, plane.col[2].to_3d() + ) max_dim = self.get_max_bound_box_dimension(profile1) self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim)) self.clippings.append( diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index c4956056aa..ff6ea96130 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): - is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + is_editing: bpy.props.BoolProperty( name="Is Editing Paramteric Geometry", description="Toggle editing parametric geometry.", default=False, update=update_is_editing, ) - geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + geometry_source: bpy.props.EnumProperty( name="Geometry Source", items=[ ("GEONODES", "Geometry Nodes", ""), ("IFCSVERCHOK", "IFC Sverchok", ""), ], ) - geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration] + geo_nodes: bpy.props.PointerProperty( name="Geometry Nodes", description="Geometry nodes tree to use as a source for representation.", type=bpy.types.GeometryNodeTree, @@ -1750,7 +1750,7 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup): poll=lambda self, node_tree: not node_tree.name.startswith("BBIM_EPG"), ) - sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration] + sverchok_nodes: bpy.props.PointerProperty( name="Sverchok Nodes", description="Sverchok node tree to use as a source for representation.", type=bpy.types.NodeTree, diff --git a/src/bonsai/bonsai/bim/module/model/task.py b/src/bonsai/bonsai/bim/module/model/task.py index a6fe2607a2..72d2ee4556 100644 --- a/src/bonsai/bonsai/bim/module/model/task.py +++ b/src/bonsai/bonsai/bim/module/model/task.py @@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings): return task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask")) qto = ifcopenshell.api.pset.add_qto( - ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" + ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument] ) ifcopenshell.api.pset.edit_qto( ifc_file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] qto=qto, properties={ "StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days, diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 441566e3e6..b5d2f5fa77 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -468,14 +468,16 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator): existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle - coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve) - coord_list = [ - (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list - ] # Reset the transformation and returns to the original points with 0 degrees - coord_list = [ - (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list - ] # Apply the transformation for the new x_angle - builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list) + profiles = extrusion.SweptArea.Profiles if extrusion.SweptArea.is_a("IfcCompositeProfileDef") else [extrusion.SweptArea] + for profile in profiles: + coord_list = builder.get_polyline_coords(profile.OuterCurve) + coord_list = [ + (p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list + ] # Reset the transformation and returns to the original points with 0 degrees + coord_list = [ + (p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list + ] # Apply the transformation for the new x_angle + builder.set_polyline_coords(profile.OuterCurve, coord_list) # The extrusion direction calculated previously default to the positive direction # Here we set the extrusion direction to negative if that's the case @@ -1268,27 +1270,6 @@ class DumbWallJoiner: bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2) return wall2 - def join_Z(self, wall1, slab2): - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(slab2) - - for rel in element1.ConnectedFrom: - if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP": - ifcopenshell.api.geometry.disconnect_element( - tool.Ifc.get(), - relating_element=rel.RelatingElement, - related_element=element1, - ) - - ifcopenshell.api.geometry.connect_element( - tool.Ifc.get(), - relating_element=element2, - related_element=element1, - description="TOP", - ) - - tool.Model.recreate_wall(element1, wall1) - def set_axis(self, wall, p1, p2): axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW") builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get()) @@ -1333,29 +1314,6 @@ class DumbWallJoiner: self.set_axis(element1, p1, p2) tool.Model.recreate_wall(element1, wall1) - def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None: - element1 = tool.Ifc.get_entity(wall1) - element2 = tool.Ifc.get_entity(wall2) - axis1 = tool.Model.get_wall_axis(wall1) - axis2 = tool.Model.get_wall_axis(wall2) - intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"]) - if intersect: - intersect, _ = intersect - else: - return - connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART" - - ifcopenshell.api.geometry.connect_path( - tool.Ifc.get(), - related_element=element1, - relating_element=element2, - relating_connection="ATPATH", - related_connection=connection, - description="BUTT", - ) - - tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"]) - def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None: wall1 = tool.Ifc.get_entity(obj1) wall2 = tool.Ifc.get_entity(obj2) diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 5f7cf7699d..828fc2c21f 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -943,7 +943,7 @@ class EditObjectUI: if "LAYER2" in AuthoringData.data["selected_material_usages"]: row = cls.layout.row(align=True) if ui_context != "TOOL_HEADER" else row add_layout_hotkey_operator( - cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context + cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_walls_to_underside.__doc__, ui_context ) if AuthoringData.data["is_flippable_element"]: diff --git a/src/bonsai/bonsai/bim/module/owner/operator.py b/src/bonsai/bonsai/bim/module/owner/operator.py index 2470fd8227..b934337b64 100644 --- a/src/bonsai/bonsai/bim/module/owner/operator.py +++ b/src/bonsai/bonsai/bim/module/owner/operator.py @@ -33,7 +33,7 @@ class EnableEditingPerson(bpy.types.Operator): bl_idname = "bim.enable_editing_person" bl_label = "Enable Editing Person" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -75,7 +75,7 @@ class RemovePerson(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_person" bl_label = "Remove Person" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -88,7 +88,7 @@ class AddPersonAttribute(bpy.types.Operator): bl_idname = "bim.add_person_attribute" bl_label = "Add Person Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)), ) @@ -104,10 +104,10 @@ class RemovePersonAttribute(bpy.types.Operator): bl_idname = "bim.remove_person_attribute" bl_label = "Remove Person Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)), ) - id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + id: bpy.props.IntProperty() if TYPE_CHECKING: name: tool.Owner.PersonAttributeType # pyright: ignore[reportIncompatibleVariableOverride] @@ -122,7 +122,7 @@ class EnableEditingRole(bpy.types.Operator): bl_idname = "bim.enable_editing_role" bl_label = "Enable Editing Role" bl_options = {"REGISTER", "UNDO"} - role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + role: bpy.props.IntProperty() if TYPE_CHECKING: role: int @@ -146,7 +146,7 @@ class AddRole(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_role" bl_label = "Add Role" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + parent: bpy.props.IntProperty() if TYPE_CHECKING: parent: int @@ -168,7 +168,7 @@ class RemoveRole(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_role" bl_label = "Remove Role" bl_options = {"REGISTER", "UNDO"} - role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + role: bpy.props.IntProperty() if TYPE_CHECKING: role: int @@ -181,8 +181,8 @@ class AddAddress(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_address" bl_label = "Add Address" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + parent: bpy.props.IntProperty() + ifc_class: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(ADDRESS_TYPE)), ) @@ -198,7 +198,7 @@ class AddAddressAttribute(bpy.types.Operator): bl_idname = "bim.add_address_attribute" bl_label = "Add Address Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)), ) @@ -214,10 +214,10 @@ class RemoveAddressAttribute(bpy.types.Operator): bl_idname = "bim.remove_address_attribute" bl_label = "Remove Address Attribute" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + name: bpy.props.EnumProperty( items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)), ) - id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + id: bpy.props.IntProperty() if TYPE_CHECKING: name: tool.Owner.AddressAttributeType # pyright: ignore[reportIncompatibleVariableOverride] @@ -232,7 +232,7 @@ class EnableEditingAddress(bpy.types.Operator): bl_idname = "bim.enable_editing_address" bl_label = "Enable Editing Address" bl_options = {"REGISTER", "UNDO"} - address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + address: bpy.props.IntProperty() if TYPE_CHECKING: address: int @@ -265,7 +265,7 @@ class RemoveAddress(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_address" bl_label = "Remove Address" bl_options = {"REGISTER", "UNDO"} - address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + address: bpy.props.IntProperty() if TYPE_CHECKING: address: int @@ -278,7 +278,7 @@ class EnableEditingOrganisation(bpy.types.Operator): bl_idname = "bim.enable_editing_organisation" bl_label = "Enable Editing Organisation" bl_options = {"REGISTER", "UNDO"} - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + organisation: bpy.props.IntProperty() if TYPE_CHECKING: organisation: int @@ -320,7 +320,7 @@ class RemoveOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_organisation" bl_label = "Remove Organisation" bl_options = {"REGISTER", "UNDO"} - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + organisation: bpy.props.IntProperty() if TYPE_CHECKING: organisation: int @@ -333,8 +333,8 @@ class AddPersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_person_and_organisation" bl_label = "Add Person And Organisation" bl_options = {"REGISTER", "UNDO"} - person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person: bpy.props.IntProperty() + organisation: bpy.props.IntProperty() if TYPE_CHECKING: person: int @@ -350,7 +350,7 @@ class RemovePersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_person_and_organisation" bl_label = "Remove Person And Organisation" bl_options = {"REGISTER", "UNDO"} - person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + person_and_organisation: bpy.props.IntProperty() if TYPE_CHECKING: person_and_organisation: int @@ -365,7 +365,7 @@ class SetUser(bpy.types.Operator): bl_idname = "bim.set_user" bl_label = "Set User" bl_options = {"REGISTER", "UNDO"} - user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + user: bpy.props.IntProperty() if TYPE_CHECKING: user: int @@ -401,7 +401,7 @@ class EnableEditingActor(bpy.types.Operator): bl_idname = "bim.enable_editing_actor" bl_label = "Enable Editing Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -434,7 +434,7 @@ class RemoveActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.remove_actor" bl_label = "Remove Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -447,7 +447,7 @@ class AssignActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_actor" bl_label = "Assign Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -462,7 +462,7 @@ class UnassignActor(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_actor" bl_label = "Unassign Actor" bl_options = {"REGISTER", "UNDO"} - actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + actor: bpy.props.IntProperty() if TYPE_CHECKING: actor: int @@ -481,7 +481,7 @@ class RemoveApplication(bpy.types.Operator, tool.Ifc.Operator): "Remove provided IfcApplication." "\n\nFor safety will only work on applications without inverses (they are typically marked as '(unused)'." ) - application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + application_id: bpy.props.IntProperty() if TYPE_CHECKING: application_id: int @@ -525,7 +525,7 @@ class EnableEditingApplication(bpy.types.Operator): bl_idname = "bim.enable_editing_application" bl_label = "Enable Editing Application" bl_options = {"REGISTER", "UNDO"} - application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + application_id: bpy.props.IntProperty() if TYPE_CHECKING: application_id: int diff --git a/src/bonsai/bonsai/bim/module/project/__init__.py b/src/bonsai/bonsai/bim/module/project/__init__.py index db642c18c2..e83da49c5b 100644 --- a/src/bonsai/bonsai/bim/module/project/__init__.py +++ b/src/bonsai/bonsai/bim/module/project/__init__.py @@ -76,6 +76,7 @@ classes = ( operator.UnlinkIfc, operator.UnloadLink, workspace.ExploreHotkey, + operator.GenerateUVMap, prop.LibraryBreadcrumb, prop.LibraryElement, prop.FilterCategory, diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 1cc5518eb4..d38c4f3b68 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -86,9 +86,7 @@ class NewProject(bpy.types.Operator): bl_label = "New Project" bl_options = {"REGISTER", "UNDO"} bl_description = "Start a new IFC project in a fresh session" - preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] - items=[(i, i, "") for i in get_args(PresetType)] - ) + preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)]) if TYPE_CHECKING: preset: PresetType @@ -182,6 +180,11 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper): append_all: bpy.props.BoolProperty(default=False) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + if TYPE_CHECKING: + filter_glob: str + append_all: bool + use_relative_path: bool + reload_previous_file = False def invoke(self, context, event): @@ -558,8 +561,12 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator): class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.append_library_element_by_query" bl_label = "Append Library Element By Query" + query: bpy.props.StringProperty(name="Query") + if TYPE_CHECKING: + query: str + @classmethod def poll(cls, context): return tool.Ifc.get() @@ -591,6 +598,11 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): prop_index: bpy.props.IntProperty() assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}) + if TYPE_CHECKING: + definition: int + prop_index: int + assume_unique_by_name: bool + file: ifcopenshell.file @classmethod @@ -618,8 +630,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if not element: return {"FINISHED"} if element.is_a("IfcTypeProduct"): - # Store opening template from library if it exists - self.store_opening_template_from_library(element, library_file) self.import_type_from_ifc(element, context) elif element.is_a("IfcProduct"): # NOTE: Non-types are not exposed in UI directly @@ -720,53 +730,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator): if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()): ifc_importer.create_style(element) - def store_opening_template_from_library( - self, element: ifcopenshell.entity_instance, library_file: ifcopenshell.file - ) -> None: - """ - Find an opening representation in the library and copy it to the current file - as a template. Store the template ID on the type for later retrieval. - """ - try: - library_element = library_file.by_guid(element.GlobalId) - except: - return - - # Find occurrences with openings in the library - library_occurrences = ifcopenshell.util.element.get_types(library_element) - - for occurrence in library_occurrences: - if not getattr(occurrence, "FillsVoids", None): - continue - - library_opening = occurrence.FillsVoids[0].RelatingOpeningElement - library_opening_rep = ifcopenshell.util.representation.get_representation( - library_opening, "Model", "Body", "MODEL_VIEW" - ) - - if not library_opening_rep: - continue - - # Check if mapped representation - if ( - library_opening_rep.RepresentationType == "MappedRepresentation" - and len(library_opening_rep.Items) == 1 - and library_opening_rep.Items[0].is_a("IfcMappedItem") - ): - - mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation - - # Store ALL representation types (Tessellation, SweptSolid, etc.) - template_rep = ifcopenshell.util.element.copy_deep( - self.file, mapped_rep, exclude=["IfcGeometricRepresentationContext"] - ) - - # Store reference in type's Description - current_desc = element.Description or "" - element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}" - return - break - class EditProjectLibrary(bpy.types.Operator): bl_idname = "bim.edit_project_library" @@ -1016,6 +979,15 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) filename_ext = ".ifc" + if TYPE_CHECKING: + filepath: str + filter_glob: str + is_advanced: bool + use_relative_path: bool + should_start_fresh_session: bool + import_without_ifc_data: bool + use_detailed_tooltip: bool + @classmethod def description(cls, context, properties): tooltip = cls.bl_description @@ -1116,7 +1088,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper): else: return self.finish_loading_project(context) - def finish_loading_project(self, context): + def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]: try: filepath = self.get_filepath() if not self.is_existing_ifc_file(): @@ -1316,6 +1288,9 @@ class ToggleFilterCategories(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} should_select: bpy.props.BoolProperty(name="Should Select", default=True) + if TYPE_CHECKING: + should_select: bool + def execute(self, context): props = tool.Project.get_project_props() for filter_category in props.filter_categories: @@ -1338,6 +1313,14 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): default=False, ) use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + query: bpy.props.StringProperty( + name="Query", + description=( + "Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n" + "Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement." + ), + ) + filename_ext = ".ifc" if TYPE_CHECKING: @@ -1347,20 +1330,25 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): filter_glob: str use_relative_path: bool use_cache: bool + query: str def draw(self, context): + assert self.layout pprops = tool.Project.get_project_props() row = self.layout.row() row.prop(self, "use_relative_path") row = self.layout.row() row.prop(self, "use_cache") row = self.layout.row() - row.prop(pprops, "false_origin_mode") + row.label(text="False Origin Mode:") + row = self.layout.row() + row.prop(pprops, "false_origin_mode", text="") if pprops.false_origin_mode == "MANUAL": row = self.layout.row() row.prop(pprops, "false_origin") row = self.layout.row() row.prop(pprops, "project_north") + self.layout.prop(self, "query", placeholder="IfcElement") def _execute(self, context): start = time.time() @@ -1393,7 +1381,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator): new.ifc_definition_id = reference.id() new.name = filepath new.filepath = filepath - bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache) + bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query) class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): @@ -1401,8 +1389,12 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Unlink IFC" bl_options = {"REGISTER", "UNDO"} bl_description = "Remove the selected file from the link list" + link_index: bpy.props.IntProperty(name="Link Index") + if TYPE_CHECKING: + link_index: int + def _execute(self, context): props = tool.Project.get_project_props() link = props.links[self.link_index] @@ -1421,8 +1413,12 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Unload Link" bl_options = {"REGISTER", "UNDO"} bl_description = "Unload the selected linked file" + link_index: bpy.props.IntProperty(name="Link Index") + if TYPE_CHECKING: + link_index: int + def _execute(self, context): link = tool.Project.get_project_props().links[self.link_index] if obj := tool.Project.get_link_empty_handle(link): @@ -1444,12 +1440,14 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Load the selected file" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] - use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") + use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) + query: bpy.props.StringProperty() if TYPE_CHECKING: link_index: int use_cache: bool + query: str def _execute(self, context): self.link = tool.Project.get_project_props().links[self.link_index] @@ -1491,8 +1489,20 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator): def link_ifc(self) -> Union[set[str], None]: blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend") h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5") + json_filepath = self.filepath_.with_suffix(".ifc.cache.json") - if not self.use_cache and blend_filepath.exists(): + def should_clear_cache() -> bool: + if not self.use_cache: + return True + if not blend_filepath.exists(): + return False + data = json.loads(json_filepath.read_text()) + # Empty 'query' - model loaded without custom query. + # Missing 'query' - model was loaded before custom queries were introduced in Bonsai. + query = data.get("query", "") + return query != self.query + + if should_clear_cache(): os.remove(blend_filepath) if not blend_filepath.exists(): @@ -1520,7 +1530,7 @@ def run(): pprops.project_north = "{pprops.project_north}" # Use absolute path to be safe from cwd changes. try: - bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}") + bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)}) except RuntimeError as e: # Operator failed (returned CANCELLED with error report) print(f"Failed to load linked project: {{e}}") @@ -1606,8 +1616,12 @@ class ReloadLink(bpy.types.Operator): bl_label = "Reload Link" bl_options = {"REGISTER", "UNDO"} bl_description = "Reload the selected file" + link_index: bpy.props.IntProperty(name="Link Index") + if TYPE_CHECKING: + link_index: int + def execute(self, context): bpy.ops.bim.unload_link(link_index=self.link_index) return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"} @@ -1618,8 +1632,12 @@ class ToggleLinkSelectability(bpy.types.Operator): bl_label = "Toggle Link Selectability" bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle selectability" + link_index: bpy.props.IntProperty(name="Link Index") + if TYPE_CHECKING: + link_index: int + def execute(self, context): props = tool.Project.get_project_props() link = props.links[self.link_index] @@ -1647,8 +1665,8 @@ class ToggleLinkVisibility(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} bl_description = "Toggle visibility between SOLID and WIREFRAME" - link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration] - mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + link_index: bpy.props.IntProperty(name="Link Index") + mode: bpy.props.EnumProperty( name="Visibility Mode", items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")), ) @@ -1788,8 +1806,12 @@ class SelectLinkHandle(bpy.types.Operator): bl_label = "Select Link Handle" bl_options = {"REGISTER", "UNDO"} bl_description = "Select link empty object handle" + link_index: bpy.props.IntProperty(name="Link Index") + if TYPE_CHECKING: + link_index: int + def execute(self, context): props = tool.Project.get_project_props() link = props.links[self.link_index] @@ -1807,7 +1829,7 @@ class SelectLinkedModelElement(bpy.types.Operator): bl_options = {"REGISTER"} bl_description = "Select an element in the currently selected linked model by providing GlobalId." - guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration] + guid: bpy.props.StringProperty(name="GlobalId") if TYPE_CHECKING: guid: str @@ -1852,6 +1874,13 @@ class ExportIFC(bpy.types.Operator, ExportHelper): should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"}) use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False) + if TYPE_CHECKING: + filter_glob: str + json_version: str + json_compact: bool + should_save_as: bool + use_relative_path: bool + @classmethod def poll(cls, context): return tool.Ifc.get() @@ -2000,6 +2029,12 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file." bl_options = {"REGISTER", "UNDO"} + query: bpy.props.StringProperty() + """See ``bim.link_ifc``.""" + + if TYPE_CHECKING: + query: str + file: ifcopenshell.file meshes: dict[str, bpy.types.Mesh] # Material names is derived from diffuse as in 'r-g-b-a'. @@ -2049,14 +2084,17 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): tool.Loader.settings.context_settings = tool.Loader.create_settings() tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True) - self.elements = set(self.file.by_type("IfcElement")) - if self.file.schema in ("IFC2X3", "IFC4"): - self.elements |= set(self.file.by_type("IfcProxy")) - if self.file.schema == "IFC2X3": - self.elements |= set(self.file.by_type("IfcSpatialStructureElement")) + if self.query: + self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query) else: - self.elements |= set(self.file.by_type("IfcSpatialElement")) - self.elements -= set(self.file.by_type("IfcFeatureElement")) + self.elements = set(self.file.by_type("IfcElement")) + if self.file.schema in ("IFC2X3", "IFC4"): + self.elements |= set(self.file.by_type("IfcProxy")) + if self.file.schema == "IFC2X3": + self.elements |= set(self.file.by_type("IfcSpatialStructureElement")) + else: + self.elements |= set(self.file.by_type("IfcSpatialElement")) + self.elements -= set(self.file.by_type("IfcFeatureElement")) if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin: tool.Loader.set_manual_blender_offset(self.file) @@ -2081,6 +2119,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper): "false_origin_mode": pprops.false_origin_mode, "false_origin": pprops.false_origin, "project_north": pprops.project_north, + "query": self.query, } with open(self.json_filepath, "w") as f: json.dump(data, f) @@ -2380,8 +2419,8 @@ class HideQueriedLinkedElement(bpy.types.Operator): ) bl_options = {"REGISTER", "UNDO"} - unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] - hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) + hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: unhide_all: bool @@ -2495,7 +2534,7 @@ class EnableCulling(bpy.types.Operator): self.total_mousemoves = 0 self.cullable_objects = [] - def modal(self, context, event): + def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]: if not LinksData.enable_culling: for obj in bpy.context.visible_objects: if obj.type == "MESH" and obj.name.startswith("Ifc"): @@ -2526,7 +2565,7 @@ class EnableCulling(bpy.types.Operator): return {"PASS_THROUGH"} - def is_view_changed(self, context): + def is_view_changed(self, context: bpy.types.Context) -> bool: view_matrix = context.region_data.view_matrix projection_matrix = context.region_data.window_matrix vp_matrix = projection_matrix @ view_matrix @@ -2541,7 +2580,7 @@ class EnableCulling(bpy.types.Operator): return True return False - def is_object_in_view(self, obj, context, camera_position): + def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool: # Get the view matrix and the projection matrix from the active viewport view_matrix = context.region_data.view_matrix projection_matrix = context.region_data.window_matrix @@ -2568,7 +2607,7 @@ class EnableCulling(bpy.types.Operator): return False return True - def invoke(self, context, event): + def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]: LinksData.enable_culling = True self.cullable_objects = [] for obj in bpy.context.visible_objects: @@ -2858,6 +2897,10 @@ class IFCFileHandlerOperator(bpy.types.Operator): directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}) files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}) + if TYPE_CHECKING: + directory: str + files: list[bpy.types.OperatorFileListElement] + def invoke(self, context, event): # Keeping code in .invoke() as we'll probably add some # popup windows later. @@ -2909,6 +2952,9 @@ class MeasureTool(bpy.types.Operator, PolylineOperator): measure_type: bpy.props.StringProperty() + if TYPE_CHECKING: + measure_type: str + @classmethod def poll(cls, context): return context.space_data.type == "VIEW_3D" @@ -3005,6 +3051,9 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator): measure_type: bpy.props.StringProperty() + if TYPE_CHECKING: + measure_type: str + @classmethod def poll(cls, context): return context.space_data.type == "VIEW_3D" @@ -3107,7 +3156,10 @@ class ClearMeasurement(bpy.types.Operator): @classmethod def poll(cls, context): polyline_props = tool.Model.get_polyline_props() - return len(polyline_props.measurement_polyline) > 0 + if len(polyline_props.measurement_polyline) > 0: + return True + cls.poll_message_set("No measurement to clear.") + return False def execute(self, context): polyline_props = tool.Model.get_polyline_props() @@ -3211,7 +3263,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): super().invoke(context, event) return {"RUNNING_MODAL"} - def cancel_tool(self, context): + def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]: context.workspace.status_text_set(text=None) if hasattr(self, "tool_state"): self.tool_state.plane_method = None @@ -3219,7 +3271,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): tool.Blender.update_viewport() return {"CANCELLED"} - def handle_custom_instructions(self, context): + def handle_custom_instructions(self, context: bpy.types.Context) -> None: if len(self.selected_points) == 0: instruction_text = "Click First Point on Image" elif len(self.selected_points) == 1: @@ -3234,14 +3286,14 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator): context.workspace.status_text_set(text=instruction_text) - def calculate_distance(self): + def calculate_distance(self) -> None: if len(self.selected_points) == 2: point1 = self.selected_points[0] point2 = self.selected_points[1] distance_3d = (point2 - point1).length self.calculated_distance = distance_3d / self.unit_scale - def apply_scaling(self, context): + def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]: if len(self.selected_points) != 2: self.report({"ERROR"}, "Two points must be selected") return {"CANCELLED"} @@ -3301,6 +3353,9 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} filepath: bpy.props.StringProperty(name="IFC File Path", default="") + if TYPE_CHECKING: + filepath: str + def execute(self, context): ifc_file = self.filepath if not ifc_file: @@ -3333,3 +3388,19 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator): bpy.app.handlers.load_post.append(load_handler) bpy.ops.wm.open_mainfile(filepath=metadata_path) return {"FINISHED"} + + +class GenerateUVMap(bpy.types.Operator): + bl_idname = "bim.generate_uv_map" + bl_label = "Generate UV Map" + bl_description = "Generate UV map for selected mesh." + bl_options = {"REGISTER", "UNDO", "INTERNAL"} + + def execute(self, context): + obj = context.active_object + if not obj or not isinstance(obj.data, bpy.types.Mesh): + self.report({"ERROR"}, "No valid mesh selected.") + return {"CANCELLED"} + tool.Loader.load_generated_uv_map(obj.data) + self.report({"INFO"}, "Generated UV map for selected mesh.") + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 5ba0cf6f28..1408d2f786 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup): ), default=False, ) - should_cache: BoolProperty( # pyright: ignore[reportRedeclaration] + should_cache: BoolProperty( name="Cache", description=( "Cache loaded geometry to .h5 file in your cache directory (see in preferences) " diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 98122100d2..5e828ddd9b 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -19,6 +19,7 @@ from __future__ import annotations import os +import shutil from typing import TYPE_CHECKING import bpy @@ -384,6 +385,18 @@ class BIM_PT_new_project_wizard(Panel): row = self.layout.row() row.operator("bim.create_project") + if shutil.which("git"): + git_props = context.scene.IfcGitProperties + box = self.layout.box() + row = box.row() + row.label(text="Clone a remote Git repository") + row = box.row() + row.prop(git_props, "remote_url") + row = box.row() + row.prop(git_props, "local_folder") + row = box.row() + row.operator("ifcgit.clone_repo", icon="IMPORT") + class BIM_PT_project_library(Panel): bl_label = "Project Library" @@ -496,7 +509,7 @@ class BIM_PT_links(Panel): row.operator("bim.reload_link", text="", icon="FILE_REFRESH").link_index = index else: row.operator("bim.load_link", text="", icon="LINKED").link_index = index - row.operator("bim.unlink_ifc", text="", icon="X").link_index = index + row.operator("bim.unlink_ifc", text="", icon="X").link_index = index self.layout.template_list("BIM_UL_links", "", self.props, "links", self.props, "active_link_index") if LinksData.enable_culling: diff --git a/src/bonsai/bonsai/bim/module/project/workspace.py b/src/bonsai/bonsai/bim/module/project/workspace.py index a01a144b31..bd60f4975a 100644 --- a/src/bonsai/bonsai/bim/module/project/workspace.py +++ b/src/bonsai/bonsai/bim/module/project/workspace.py @@ -71,21 +71,26 @@ class ExploreTool(bpy.types.WorkSpaceTool): row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_M") - row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Measure Tool", icon="CON_DISTLIMIT") op.hotkey = "S_M" row = layout.row(align=True) row.prop(prop, "measurement_type", text="Measure Type", expand=True, icon_only=True, emboss=True) - row = layout.row(align=True) op = row.operator("bim.clear_measurement", text="", icon="X") row = layout.row(align=True) row.label(text="", icon="EVENT_SHIFT") row.label(text="", icon="EVENT_S") - row = layout.row(align=True) op = row.operator("bim.explore_hotkey", text="Image Scaling Tool", icon="IMAGE_PLANE") op.hotkey = "S_S" - op.description = "Scale Image Annotation. Allows to scale an IfcReferenceImage. Select image, select tool. Check lower left corner instructions to select two points and provide real distance between them" + op.description = ( + "Scale Image Annotation.\n\n" + "Allows to scale an IfcReferenceImage.\n\n" + "Select image, select tool. " + "Check lower left corner instructions to select two points and provide real distance between them" + ) + + row = layout.row(align=True) + row.operator("bim.generate_uv_map", icon="UV") class ExploreHotkey(bpy.types.Operator): diff --git a/src/bonsai/bonsai/bim/module/pset/operator.py b/src/bonsai/bonsai/bim/module/pset/operator.py index d7755cf80a..e37b4b4a00 100644 --- a/src/bonsai/bonsai/bim/module/pset/operator.py +++ b/src/bonsai/bonsai/bim/module/pset/operator.py @@ -88,6 +88,44 @@ class DisablePsetEditing(bpy.types.Operator, tool.Ifc.Operator): props.active_pset_type = "-" +def _regenerate_parametric_dimension(file, annotation): + """Regenerate a single parametric dimension annotation after a pset edit.""" + try: + import json + import numpy as np + import ifcopenshell.util.element + import ifcopenshell.api.drawing as drawing_api + import bonsai.tool as _tool + from bonsai.bim.module.drawing.operator import _update_blender_curve + + pset_data = ifcopenshell.util.element.get_pset(annotation, "BBIM_Dimension") + if not pset_data or not pset_data.get("Anchors"): + return + + anchors = json.loads(pset_data["Anchors"]) + placement_override = {} + for a in anchors: + guid = a.get("guid") + if not guid: + continue + try: + elem = file.by_guid(guid) + elem_obj = _tool.Ifc.get_object(elem) + if elem_obj: + placement_override[elem.id()] = np.array(elem_obj.matrix_world) + except Exception: + pass + + resolved_pts = drawing_api.regenerate_dimension( + file, annotation, placement_override=placement_override + ) + if resolved_pts: + _update_blender_curve(annotation, resolved_pts) + except Exception: + import traceback + traceback.print_exc() + + class EditPset(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_pset" bl_label = "Edit Pset" @@ -150,7 +188,12 @@ class EditPset(bpy.types.Operator, tool.Ifc.Operator): ) if tool.Cost.has_schedules(): tool.Cost.update_cost_items(pset=pset) + is_bbim_dimension = props.active_pset_name == "BBIM_Dimension" and element.is_a("IfcAnnotation") + bpy.ops.bim.disable_pset_editing(obj=self.obj, obj_type=self.obj_type) + if is_bbim_dimension: + _regenerate_parametric_dimension(self.file, element) + tool.Blender.update_viewport() @@ -240,7 +283,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Property To Selection" bl_options = {"REGISTER", "UNDO"} - name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + name: bpy.props.StringProperty() if TYPE_CHECKING: name: str @@ -280,10 +323,10 @@ class BIM_OT_add_property_to_edit(bpy.types.Operator): bl_label = "Add Property to Edit" bl_idname = "bim.add_property_to_edit" bl_options = {"REGISTER", "UNDO"} - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) - index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty(default=-1) if TYPE_CHECKING: option: tool.Pset.BulkOperationType @@ -307,9 +350,9 @@ class BIM_OT_remove_property_to_edit(bpy.types.Operator): bl_label = "Remove Property from Editing" bl_idname = "bim.remove_property_to_edit" bl_options = {"REGISTER", "UNDO"} - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] - index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration] - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() + index2: bpy.props.IntProperty(default=-1) + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) @@ -336,7 +379,7 @@ class BIM_OT_bulk_edit_clear_list(bpy.types.Operator): bl_label = "Clear List of Properties" bl_idname = "bim.pset_bulk_edit_clear_list" bl_options = {"REGISTER", "UNDO"} - option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + option: bpy.props.EnumProperty( items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES], ) diff --git a/src/bonsai/bonsai/bim/module/pset/prop.py b/src/bonsai/bonsai/bim/module/pset/prop.py index 1777fa0f94..786e6a62a2 100644 --- a/src/bonsai/bonsai/bim/module/pset/prop.py +++ b/src/bonsai/bonsai/bim/module/pset/prop.py @@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup): qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"}) # Bulk operations. - psets_to_delete: CollectionProperty(type=DeletePsetEntry) # pyright: ignore[reportRedeclaration] - psets_to_rename: CollectionProperty(type=RenamePropertyEntry) # pyright: ignore[reportRedeclaration] - psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) # pyright: ignore[reportRedeclaration] + psets_to_delete: CollectionProperty(type=DeletePsetEntry) + psets_to_rename: CollectionProperty(type=RenamePropertyEntry) + psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) if TYPE_CHECKING: pset_filter: str diff --git a/src/bonsai/bonsai/bim/module/qto/calculator.py b/src/bonsai/bonsai/bim/module/qto/calculator.py index c0e5aa474f..638d603f89 100644 --- a/src/bonsai/bonsai/bim/module/qto/calculator.py +++ b/src/bonsai/bonsai/bim/module/qto/calculator.py @@ -321,7 +321,7 @@ def get_gross_perimeter(o: bpy.types.Object) -> float: return gross_perimeter -def get_space_net_perimeter(obj: bpy.types.Object) -> float: +def get_space_net_perimeter(obj: bpy.types.Object) -> None: pass diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 38097cb974..f55dcf31b7 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -619,7 +619,7 @@ class SelectFilterElements(bpy.types.Operator): return {"FINISHED"} -class ApplyFilterFromText(Operator, tool.Ifc.Operator): +class ApplyFilterFromText(Operator): bl_idname = "bim.apply_filter_from_text" bl_label = "Apply Filter Configuration" bl_description = "Apply the JSON filter configuration from the current text block" @@ -799,7 +799,7 @@ class SelectQueryElements(Operator): bl_description = "Select elements matching an provided selector query" bl_options = {"REGISTER", "UNDO"} - query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration] + query: StringProperty(name="Query") if TYPE_CHECKING: query: str @@ -829,12 +829,12 @@ class SaveSearch(Operator, tool.Ifc.Operator): # Extra item so it will be easy to select current text. return [text] + SaveSearch.name_search_items - name: StringProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty( name="Name", search=get_name_search_items, search_options={"SORT"}, ) - module: StringProperty() # pyright: ignore[reportRedeclaration] + module: StringProperty() def update_use_all_ifcgroups(self, context: object = None) -> None: ifc_file = tool.Ifc.get() @@ -845,7 +845,7 @@ class SaveSearch(Operator, tool.Ifc.Operator): } self.name_search_items[:] = natsorted(groups) - use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration] + use_all_ifcgroups: BoolProperty( name="Use Any IfcGroup", description=( "By default we're targeting only IfcGroups with SEARCH ObjectType " @@ -1440,7 +1440,7 @@ class ShowAllElements(Operator): return {"FINISHED"} -class SelectSimilar(Operator, tool.Ifc.Operator): +class SelectSimilar(Operator): bl_idname = "bim.select_similar" bl_label = "Select Similar" bl_options = {"REGISTER", "UNDO"} diff --git a/src/bonsai/bonsai/bim/module/sequence/operator.py b/src/bonsai/bonsai/bim/module/sequence/operator.py index 2b4c317cd8..fb97d7152a 100644 --- a/src/bonsai/bonsai/bim/module/sequence/operator.py +++ b/src/bonsai/bonsai/bim/module/sequence/operator.py @@ -106,7 +106,7 @@ class ActivateStatusFilters(bpy.types.Operator): bl_description = "Filter and display objects based on currently selected IFC statuses" bl_options = {"REGISTER", "UNDO"} - only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + only_if_enabled: bpy.props.BoolProperty( name="Only If Filters are Enabled", description="Activate status filters only in case if they were enabled from the UI before.", default=False, @@ -137,7 +137,7 @@ class SelectStatusFilter(bpy.types.Operator): bl_description = "Select elements with currently selected status" bl_options = {"REGISTER", "UNDO"} - status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + status: bpy.props.StringProperty() if TYPE_CHECKING: status: tool.Sequence.ElementStatusUI @@ -156,7 +156,7 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status." bl_options = {"REGISTER", "UNDO"} - should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + should_override_previous_status: bpy.props.BoolProperty( name="Override Previous Status", description=( "Whether assigning new status should override previous one.\n\n" @@ -165,8 +165,8 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator): ), default=True, ) - status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] - should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + status: bpy.props.StringProperty() + should_unassign_status: bpy.props.BoolProperty( options={"SKIP_SAVE"}, ) @@ -415,7 +415,7 @@ class CopyWorkSchedule(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy Work Schedule" bl_description = "Create a duplicate of the provided work schedule." bl_options = {"REGISTER", "UNDO"} - work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + work_schedule: bpy.props.IntProperty() if TYPE_CHECKING: work_schedule: int diff --git a/src/bonsai/bonsai/bim/module/sequence/prop.py b/src/bonsai/bonsai/bim/module/sequence/prop.py index abb1aa9f0c..97f274c421 100644 --- a/src/bonsai/bonsai/bim/module/sequence/prop.py +++ b/src/bonsai/bonsai/bim/module/sequence/prop.py @@ -412,7 +412,7 @@ WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", " class BIMWorkPlanProperties(PropertyGroup): work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute) - editing_type: EnumProperty( # pyright: ignore[reportRedeclaration] + editing_type: EnumProperty( items=[(i, i, "") for i in get_args(WorkPlanEditingType)], ) work_plans: CollectionProperty(name="Work Plans", type=WorkPlan) @@ -430,8 +430,8 @@ class BIMWorkPlanProperties(PropertyGroup): class IFCStatus(PropertyGroup): - name: StringProperty() # pyright: ignore[reportRedeclaration] - is_visible: BoolProperty( # pyright: ignore[reportRedeclaration] + name: StringProperty() + is_visible: BoolProperty( name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0] ) diff --git a/src/bonsai/bonsai/bim/module/spatial/operator.py b/src/bonsai/bonsai/bim/module/spatial/operator.py index f1eb4ec4ee..cd6bc6cab2 100644 --- a/src/bonsai/bonsai/bim/module/spatial/operator.py +++ b/src/bonsai/bonsai/bim/module/spatial/operator.py @@ -220,7 +220,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Copy to Container" bl_options = {"REGISTER", "UNDO"} - container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + container: bpy.props.IntProperty() if TYPE_CHECKING: container: int diff --git a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py index 132c305ad1..dc05fab89c 100644 --- a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py +++ b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py @@ -28,6 +28,7 @@ import ifcopenshell.util.representation import ifcopenshell.util.unit import ifcopenshell.util.unit as ifcunit import numpy as np +import numpy.typing as npt from mathutils import Vector import bonsai.tool as tool @@ -478,7 +479,7 @@ class ShaderInfo: """get the args to the point shader""" location = np.array(location) indices = [] - direction_dict = { + direction_dict: dict[str, tuple[npt.NDArray, ...]] = { "fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))), "fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))), "fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))), diff --git a/src/bonsai/bonsai/bim/module/structural/operator.py b/src/bonsai/bonsai/bim/module/structural/operator.py index 20b32c7a0c..825f52e954 100644 --- a/src/bonsai/bonsai/bim/module/structural/operator.py +++ b/src/bonsai/bonsai/bim/module/structural/operator.py @@ -167,7 +167,7 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator): bl_idname = "bim.enable_editing_structural_boundary_condition" bl_label = "Enable Editing Structural Boundary Condition" bl_options = {"REGISTER", "UNDO"} - boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + boundary_condition: bpy.props.IntProperty() if TYPE_CHECKING: boundary_condition: int @@ -186,7 +186,7 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.edit_structural_boundary_condition" bl_label = "Edit Structural Boundary Condition" bl_options = {"REGISTER", "UNDO"} - connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + connection: bpy.props.IntProperty() if TYPE_CHECKING: connection: int @@ -917,7 +917,7 @@ class EnableEditingBoundaryCondition(bpy.types.Operator): bl_idname = "bim.enable_editing_boundary_condition" bl_label = "Enable Editing Boundary Condition" bl_options = {"REGISTER", "UNDO"} - boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + boundary_condition: bpy.props.IntProperty() if TYPE_CHECKING: boundary_condition: int diff --git a/src/bonsai/bonsai/bim/module/style/prop.py b/src/bonsai/bonsai/bim/module/style/prop.py index bd62021d65..a4cfb2cb9e 100644 --- a/src/bonsai/bonsai/bim/module/style/prop.py +++ b/src/bonsai/bonsai/bim/module/style/prop.py @@ -118,6 +118,19 @@ def update_shader_graph(self: Union["Texture", "BIMStylesProperties"], context: tool.Loader.create_surface_style_with_textures(material, shading_data, textures_data) +def _make_clear_null_updater(null_prop: str): + def _update(self: "BIMStylesProperties", context: bpy.types.Context) -> None: + self[null_prop] = False + update_shader_graph(self, context) + + return _update + + +update_diffuse_colour = _make_clear_null_updater("is_diffuse_colour_null") +update_specular_colour = _make_clear_null_updater("is_specular_colour_null") +update_specular_highlight_value = _make_clear_null_updater("is_specular_highlight_null") + + UV_MODES = [ ("UV", "UV", _("Actual UV data presented on the geometry")), ("Generated", "Generated", _("Automatically-generated UV from the vertex positions of the mesh")), @@ -221,24 +234,29 @@ class BIMStylesProperties(PropertyGroup): transparency: bpy.props.FloatProperty( name="Transparency", default=0.0, min=0.0, max=1.0, update=update_shader_graph ) - # TODO: do something on null? - is_diffuse_colour_null: BoolProperty(name="Is Null") + is_diffuse_colour_null: BoolProperty(name="Is Null", update=update_shader_graph) diffuse_colour_class: EnumProperty( items=[(x, x, "") for x in get_args(ColourClass)], name="Diffuse Colour Class", - update=update_shader_graph, + update=update_diffuse_colour, ) diffuse_colour: bpy.props.FloatVectorProperty( - name="Diffuse Colour", subtype="COLOR", default=(1, 1, 1), min=0.0, max=1.0, size=3, update=update_shader_graph + name="Diffuse Colour", + subtype="COLOR", + default=(1, 1, 1), + min=0.0, + max=1.0, + size=3, + update=update_diffuse_colour, ) diffuse_colour_ratio: bpy.props.FloatProperty( - name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_shader_graph + name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_diffuse_colour ) - is_specular_colour_null: BoolProperty(name="Is Null") + is_specular_colour_null: BoolProperty(name="Is Null", update=update_shader_graph) specular_colour_class: EnumProperty( items=[(x, x, "") for x in get_args(ColourClass)], name="Specular Colour Class", - update=update_shader_graph, + update=update_specular_colour, default="IfcNormalisedRatioMeasure", ) specular_colour: bpy.props.FloatVectorProperty( @@ -248,7 +266,7 @@ class BIMStylesProperties(PropertyGroup): min=0.0, max=1.0, size=3, - update=update_shader_graph, + update=update_specular_colour, ) specular_colour_ratio: bpy.props.FloatProperty( name="Specular Ratio", @@ -256,16 +274,16 @@ class BIMStylesProperties(PropertyGroup): default=0.0, min=0.0, max=1.0, - update=update_shader_graph, + update=update_specular_colour, ) - is_specular_highlight_null: BoolProperty(name="Is Null") + is_specular_highlight_null: BoolProperty(name="Is Null", update=update_shader_graph) specular_highlight: bpy.props.FloatProperty( name="Specular Highlight", description="Used as Roughness value in PHYSICAL Reflectance Method", default=0.0, min=0.0, max=1.0, - update=update_shader_graph, + update=update_specular_highlight_value, ) reflectance_method: EnumProperty( name="Reflectance Method", diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index e1f6f5e9e4..5fe47564c0 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -54,7 +54,7 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator): bl_label = "Add System" bl_options = {"REGISTER", "UNDO"} - parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + parent_system_id: bpy.props.IntProperty() if TYPE_CHECKING: parent_system_id: int diff --git a/src/bonsai/bonsai/bim/module/web/prop.py b/src/bonsai/bonsai/bim/module/web/prop.py index 42bd272578..e9e5a33185 100644 --- a/src/bonsai/bonsai/bim/module/web/prop.py +++ b/src/bonsai/bonsai/bim/module/web/prop.py @@ -26,16 +26,16 @@ from bpy.types import PropertyGroup class WebProperties(PropertyGroup): - webserver_port: IntProperty( # pyright: ignore[reportRedeclaration] + webserver_port: IntProperty( name="Webserver Port", min=0, max=65535, ) - is_running: BoolProperty( # pyright: ignore[reportRedeclaration] + is_running: BoolProperty( name="Webserver Running Status", default=False, ) - is_connected: BoolProperty( # pyright: ignore[reportRedeclaration] + is_connected: BoolProperty( name="Connection Status", default=False, ) diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 3216478132..f1ab24c7b0 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -159,9 +159,9 @@ class SelectURIAttribute(bpy.types.Operator, ImportHelper): bl_label = "Select URI Attribute" bl_options = {"REGISTER", "UNDO"} bl_description = "Select a local file" - attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration] + attribute_data_path: bpy.props.StringProperty(name="Data Path") """Full data path to `Attribute`/string property.""" - use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration] + use_relative_path: bpy.props.BoolProperty( name="Use Relative Path", default=False, ) @@ -601,7 +601,7 @@ class CreateMacBonsaiApp(bpy.types.Operator): "ALT+click to uninstall Bonsai app if it was installed previously." ) - uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration] + uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) if TYPE_CHECKING: uninstall: bool @@ -1667,7 +1667,7 @@ class BIM_OT_attribute_add_subitem(bpy.types.Operator): bl_description = "Add subitem to the current attribute" bl_options = {"REGISTER", "UNDO"} - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path.""" if TYPE_CHECKING: @@ -1691,9 +1691,9 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator): bl_description = "Add subitem to the current attribute" bl_options = {"REGISTER", "UNDO"} - data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration] + data_path: bpy.props.StringProperty() """Full data path.""" - index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration] + index: bpy.props.IntProperty() if TYPE_CHECKING: data_path: str diff --git a/src/bonsai/bonsai/bim/prop.py b/src/bonsai/bonsai/bim/prop.py index e5d495c62a..e10243d266 100644 --- a/src/bonsai/bonsai/bim/prop.py +++ b/src/bonsai/bonsai/bim/prop.py @@ -333,7 +333,7 @@ class Attribute(PropertyGroup): filter_glob: StringProperty() is_null: BoolProperty(name="Is Null", update=update_is_null) is_selected: BoolProperty(name="Is Selected", default=False) - subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration] + subitems_values: CollectionProperty(type=StrProperty) # Attribute parameters. is_optional: BoolProperty(name="Is Optional") @@ -342,7 +342,7 @@ class Attribute(PropertyGroup): value_max: FloatProperty(description="This is used to validate int_value and float_value") value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound") special_type: StringProperty(name="Special Value Type", default="") - use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration] + use_explorer_ui: BoolProperty() metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute") update: StringProperty(name="Update", description="Custom update function to be executed") diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 526b757031..97980d92dd 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -665,7 +665,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False ) should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False) - should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration] + should_always_cache: BoolProperty( name="Always Cache Geometry", description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.", ) @@ -787,7 +787,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): doc: DocPreferences default_parameters: DefaultParameters container_hide_show_isolate: bool - mass_time_units_in_wizard: bool chain_filter_with_set_operations: bool save_metadata_blend_file: bool metadata_blend_file_suffix: str @@ -986,7 +985,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") - layout.prop(self, "mass_time_units_in_wizard") row = layout.row(align=True) row.prop(self, "chain_filter_with_set_operations") row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270" diff --git a/src/bonsai/bonsai/core/covering.py b/src/bonsai/bonsai/core/covering.py index a8c7a73f25..2ab0d39cc4 100644 --- a/src/bonsai/bonsai/core/covering.py +++ b/src/bonsai/bonsai/core/covering.py @@ -51,21 +51,11 @@ def add_instance_flooring_covering_from_cursor( if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - name = "Covering" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - - obj = spatial.get_named_obj_from_mesh(name, mesh) - + obj = spatial.create_object("Covering") spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z) - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True) def add_instance_ceiling_covering_from_cursor( @@ -95,21 +85,11 @@ def add_instance_ceiling_covering_from_cursor( if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - name = "Covering" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - - obj = spatial.get_named_obj_from_mesh(name, mesh) - + obj = spatial.create_object("Covering") spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z + ceiling_height) - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, space_polygon, polygon_is_si=True) def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spatial]) -> None: @@ -127,19 +107,7 @@ def regen_selected_covering_object(root: type[tool.Root], spatial: type[tool.Spa if isinstance(space_polygon, str): return - bm = spatial.get_bmesh_from_polygon(space_polygon, h=0, polygon_is_si=True) - - name = "Aux" - mesh = spatial.get_named_mesh_from_bmesh(name=name, bmesh=bm) - mesh = spatial.get_transformed_mesh_from_local_to_global(mesh) - obj = spatial.get_named_obj_from_mesh(name, mesh) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(active_obj, vertices=points) - body = spatial.get_body_representation(active_obj) - spatial.regen_obj_representation(active_obj, body) + spatial.set_covering_representation_from_polygon(active_obj, space_polygon, polygon_is_si=True) # TODO CHECK IF IT IS POSSIBLE TO CREATE ONLY ONE CORE FUNCTION FOR _FROM_WALLS @@ -151,22 +119,13 @@ def add_instance_flooring_coverings_from_walls(root: type[tool.Root], spatial: t union = spatial.get_union_shape_from_selected_objects() for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False) name = "Covering" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False) def add_instance_ceiling_coverings_from_walls( @@ -179,22 +138,13 @@ def add_instance_ceiling_coverings_from_walls( union = spatial.get_union_shape_from_selected_objects() for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h=0, polygon_is_si=False) name = "Covering" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) - - points = spatial.get_2d_vertices_from_obj(obj) - points = spatial.get_scaled_2d_vertices(points) - spatial.assign_type_to_obj(obj) - - spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices=points) - body = spatial.get_body_representation(obj) - spatial.regen_obj_representation(obj, body) + spatial.set_covering_representation_from_polygon(obj, poly, polygon_is_si=False) class NoDefaultContainer(Exception): diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 80ecc33d88..a606a13086 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -432,10 +432,7 @@ def update_drawing_name( if drawing_tool.get_name(drawing) != name: ifc.run("attribute.edit_attributes", product=drawing, attributes={"Name": name}) - # Update the camera object name - camera = ifc.get_object(drawing) - if camera and camera.name != name: - camera.name = name + drawing_tool.set_camera_name(drawing, name) group = drawing_tool.get_drawing_group(drawing) if drawing_tool.get_name(group) != name: diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index fb23652ebc..875a37f8c0 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -56,42 +56,50 @@ def discard_uncommitted(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: ifcgit.load_project(path_ifc) -def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None: +def commit_changes( + ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], commit_message: str, new_branch_name: str = "" +) -> None: """Commit and create new branches as required""" path_ifc = ifc.get_path() - if repo.head.is_detached: - ifcgit.git_commit(path_ifc) - ifcgit.create_new_branch() + if ifcgit.is_head_detached(): + ifcgit.git_commit(path_ifc, commit_message) + ifcgit.create_new_branch(new_branch_name) else: - ifcgit.checkout_new_branch(path_ifc) - ifcgit.git_commit(path_ifc) + if new_branch_name: + ifcgit.checkout_new_branch(path_ifc, new_branch_name) + ifcgit.git_commit(path_ifc, commit_message) -def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.add_tag(repo) +def add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + ifcgit.add_tag(repo, hexsha, tag_name, tag_message) def delete_tag(ifcgit: type[tool.IfcGit], repo: git.Repo, tag_name: git.TagReference) -> None: ifcgit.delete_tag(repo, tag_name) -def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.add_remote(repo) +def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, remote_url: str) -> None: + ifcgit.add_remote(repo, remote_name, remote_url) -def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None: - ifcgit.delete_remote(repo) +def delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str) -> None: + ifcgit.delete_remote(repo, remote_name) + + +def rename_branch(ifcgit: type[tool.IfcGit], repo: git.Repo, new_name: str) -> None: + ifcgit.rename_branch(repo, new_name) def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None: - error_message = ifcgit.push(repo, remote_name, repo.active_branch.name) + error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name()) if error_message: operator.report({"ERROR"}, error_message) -def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None: - if repo.heads: +def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: + ifcgit.clear_merge_conflicts() + if ifcgit.repo_has_commits(): ifcgit.refresh_revision_list(ifc.get_path()) @@ -125,10 +133,76 @@ def switch_revision(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None: ifcgit.decolourise() -def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: +def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> bool | None: path_ifc = ifc.get_path() ifcgit.config_ifcmerge() - ifcgit.execute_merge(path_ifc, operator) + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge(branch_name) + + if merge_result == "error": + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return False + elif merge_result == "conflict": + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + if conflicts is not None: + ifcgit.git_merge_abort() + if conflicts: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") + else: + operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly") + return False + ifcgit.commit_merge(path_ifc) + + ifcgit.clear_merge_conflicts() + ifcgit.set_display_branch() + ifcgit.git_checkout(path_ifc) + ifcgit.load_project(path_ifc) + ifcgit.refresh_revision_list(path_ifc) + ifcgit.decolourise() + + +def dry_run_merge(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.types.Operator) -> None: + path_ifc = ifc.get_path() + ifcgit.config_ifcmerge() + + branch_name = ifcgit.get_selected_branch() + if branch_name is None: + return + + mergetool = ifcgit.get_merge_tool(branch_name) + merge_result = ifcgit.git_merge_no_commit(branch_name) + + if merge_result == "error": + try: + ifcgit.git_merge_abort() + except Exception: + pass + operator.report({"ERROR"}, "Unknown IFC Merge failure") + return + + if merge_result == "conflict": + conflicts = ifcgit.git_mergetool(mergetool, path_ifc) + ifcgit.git_merge_abort() + if conflicts is not None: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge preview: conflicts found — see the panel below") + else: + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") + else: + # Clean merge or already up to date — abort the pending merge state if any + try: + ifcgit.git_merge_abort() + except Exception: + pass + ifcgit.clear_merge_conflicts() + operator.report({"INFO"}, "Merge preview: no conflicts") def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None: @@ -145,5 +219,9 @@ def install_git(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator) -> None print("install_git() not implemented") +def fetch(ifcgit: type[tool.IfcGit], remote_name: str) -> None: + ifcgit.fetch(remote_name) + + def run_git_diff(ifcgit: type[tool.IfcGit], operator: bpy.types.Operator, save_to_temp: bool) -> None: ifcgit.run_git_diff(operator, save_to_temp) diff --git a/src/bonsai/bonsai/core/model.py b/src/bonsai/bonsai/core/model.py index 7b17d5de5d..e975505381 100644 --- a/src/bonsai/bonsai/core/model.py +++ b/src/bonsai/bonsai/core/model.py @@ -109,7 +109,7 @@ def align_walls( align_type: AlignType, ): reference_obj = blender.get_active_object(is_selected=True) - if not (e := ifc.get_entity(reference_obj) or not model.get_usage_type(e) == "LAYER2"): + if not reference_obj or not (e := ifc.get_entity(reference_obj)) or not model.get_usage_type(e) == "LAYER2": reference_obj = None objs = [ o @@ -159,48 +159,6 @@ def extend_wall_to_slab( model.reload_body_representation(wall_objs) -def join_walls_TZ( - ifc: type[tool.Ifc], - blender: type[tool.Blender], - geometry: type[tool.Geometry], - joiner: DumbWallJoiner, - model: type[tool.Model], -) -> None: - selected_objs = [ - o - for o in blender.get_selected_objects() - if (e := ifc.get_entity(o)) and model.get_usage_type(e) in ("LAYER2", "LAYER3") - ] - if len(selected_objs) < 2: - raise RequireAtLeastTwoLayeredElements( - "Two or more vertically or horizontally layered elements must be selected to connect their paths together" - ) - - for obj in selected_objs: - geometry.clear_scale(obj) - - elements = [ifc.get_entity(o) for o in blender.get_selected_objects()] - layer2_elements = [] - layer3_elements = [] - for element in elements: - usage = model.get_usage_type(element) - if usage == "LAYER2": - layer2_elements.append(element) - elif usage == "LAYER3": - layer3_elements.append(element) - if layer3_elements: - target = ifc.get_object(layer3_elements[0]) - for element in layer2_elements: - joiner.join_Z(ifc.get_object(element), target) - else: - if not (active_obj := blender.get_active_object()): - active_obj = selected_objs[0] - for obj in selected_objs: - if obj == active_obj: - continue - joiner.join_T(obj, active_obj) - - class RequireTwoWallsError(Exception): pass diff --git a/src/bonsai/bonsai/core/spatial.py b/src/bonsai/bonsai/core/spatial.py index 5086a8a872..5ce6d7c253 100644 --- a/src/bonsai/bonsai/core/spatial.py +++ b/src/bonsai/bonsai/core/spatial.py @@ -219,13 +219,8 @@ def generate_space( else: assert space_polygon - bm = spatial.get_bmesh_from_polygon(space_polygon, h=h, polygon_is_si=True) - - mesh = spatial.get_named_mesh_from_bmesh(name="Space", bmesh=bm) - if element and element.is_a("IfcSpace"): - mesh = spatial.get_transformed_mesh_from_local_to_global(mesh) - spatial.edit_active_space_obj_from_mesh(mesh) + spatial.set_space_representation_from_polygon(active_obj, element, space_polygon, h, polygon_is_si=True) spatial.translate_obj_to_z_location(active_obj, z) else: if relating_type: @@ -233,12 +228,13 @@ def generate_space( else: name = "Space" - obj = spatial.get_named_obj_from_mesh(name, mesh) + obj = spatial.create_object(name) spatial.set_obj_origin_to_cursor_position_and_zero_elevation(obj) spatial.translate_obj_to_z_location(obj, z) spatial.assign_ifcspace_class_to_obj(obj) element = ifc.get_entity(obj) + spatial.set_space_representation_from_polygon(obj, element, space_polygon, h, polygon_is_si=True) if relating_type: spatial.assign_relating_type_to_element(ifc, type, element, relating_type) @@ -257,16 +253,16 @@ def generate_spaces_from_walls( for i, linear_ring in enumerate(union.interiors): poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) - bm = spatial.get_bmesh_from_polygon(poly, h, polygon_is_si=False) - name = "Space" + str(i) - obj = spatial.get_named_obj_from_bmesh(name, bmesh=bm) - - spatial.set_obj_origin_to_bboxcenter_and_zero_elevation(obj) + obj = spatial.create_object(name) + spatial.set_obj_origin_to_polygon_center(obj, poly, polygon_is_si=False) spatial.translate_obj_to_z_location(obj, z) spatial.assign_ifcspace_class_to_obj(obj) + element = ifc.get_entity(obj) + spatial.set_space_representation_from_polygon(obj, element, poly, h, polygon_is_si=False) + def toggle_space_visibility(ifc: type[tool.Ifc], spatial: type[tool.Spatial]) -> None: model = ifc.get() diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 340f9d7a64..e6a60c9deb 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -349,7 +349,10 @@ class Drawing: def enable_editing_text(cls, obj): pass def ensure_unique_drawing_name(cls, name): pass def ensure_unique_identification(cls, identification): pass + def export_font_size(cls, obj): pass + def export_symbol(cls, obj): pass def export_text_literal_attributes(cls, obj): pass + def export_wrap_length(cls, obj): pass def generate_drawing_matrix(cls, target_view, location_hint): pass def generate_drawing_name(cls, target_view, location_hint): pass def generate_reference_attributes(cls, reference, **attributes): pass @@ -402,6 +405,7 @@ class Drawing: def run_root_assign_class(cls, obj=None, ifc_class=None, predefined_type=None, should_add_representation=True, context=None, ifc_representation_class=None): pass def run_type_assign_type(cls, element=None, relating_type=None): pass def select_assigned_product(cls, drawing): pass + def set_camera_name(cls, drawing, name): pass def set_drawing_collection_name(cls, drawing, collection): pass def set_name(cls, element, name): pass def setup_annotation_object(cls, obj, object_type): pass @@ -531,6 +535,60 @@ class Ifc: def get_all_element_occurrences(cls, element): pass +@interface +class IfcGit: + def add_file_to_repo(cls, repo, path_file): pass + def add_remote(cls, repo, remote_name, remote_url): pass + def add_tag(cls, repo, hexsha, tag_name, tag_message): pass + def branches_by_hexsha(cls, repo): pass + def checkout_new_branch(cls, path_file, branch_name): pass + def clear_commits_list(cls): pass + def clone_repo(cls, remote_url, local_folder): pass + def colourise(cls, step_ids): pass + def config_ifcmerge(cls): pass + def create_new_branch(cls, branch_name): pass + def decolourise(cls): pass + def delete_remote(cls, repo, remote_name): pass + def delete_tag(cls, repo, tag_name): pass + def dos2unix(cls, path_file): pass + def commit_merge(cls, path_ifc): pass + def entity_log(cls, path_ifc, step_id): pass + def fetch(cls, remote_name): pass + def get_commits_list(cls, path_ifc, lookup): pass + def get_merge_tool(cls, branch_name): pass + def get_selected_branch(cls): pass + def git_merge(cls, branch_name): pass + def git_merge_abort(cls): pass + def git_merge_no_commit(cls, branch_name): pass + def git_mergetool(cls, mergetool, path_ifc): pass + def store_merge_conflicts(cls, conflicts): pass + def clear_merge_conflicts(cls): pass + def get_merge_conflicts(cls): pass + def set_display_branch(cls): pass + def get_active_branch_name(cls): pass + def get_ifcgit_props(cls): pass + def get_modified_step_ids(cls, step_ids): pass + def get_path_dir(cls, path_ifc): pass + def get_revisions_step_ids(cls): pass + def is_head_detached(cls): pass + def repo_has_commits(cls): pass + def git_checkout(cls, path_file): pass + def git_commit(cls, path_file, commit_message): pass + def ifc_diff_ids(cls, repo, hash_a, hash_b, path_ifc): pass + def init_repo(cls, path_dir): pass + def install_git_windows(cls, operator): pass + def is_valid_ref_format(cls, string): pass + def load_anyifc(cls, repo): pass + def load_project(cls, path_ifc): pass + def push(cls, repo, remote_name, branch_name): pass + def refresh_revision_list(cls, path_ifc): pass + def repo_from_path(cls, path): pass + def run_git_diff(cls, operator, save_to_temp): pass + def switch_to_revision_item(cls): pass + def tags_by_hexsha(cls, repo): pass + def update_step_ids(cls, step_ids, modified_step_ids): pass + + @interface class Layer: pass @@ -620,7 +678,10 @@ class Model: def import_rectangle(cls, obj, position, profile): pass def load_openings(cls, openings): pass def purge_scene_openings(cls): pass + def recalculate_walls(cls, objs): pass def regenerate_array(cls, parent, data): pass + def regenerate_profile(cls, obj): pass + def regenerate_slab(cls, obj): pass def reload_body_representation(cls, obj_or_objects): pass def replace_object_ifc_representation(cls, ifc_file, ifc_context, obj, new_representation): pass @@ -992,14 +1053,12 @@ class Spatial: def get_purged_inner_holes_poly(cls, union_geom, min_area): pass def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass def get_buffered_poly_from_linear_ring(cls, linear_ring): pass - def get_bmesh_from_polygon(cls, poly, h, polygon_is_si=False): pass - def get_named_obj_from_bmesh(cls, name, bmesh): pass - def get_named_obj_from_mesh(cls, name, mesh): pass - def get_named_mesh_from_bmesh(cls, name, bmesh): pass - def get_transformed_mesh_from_local_to_global(cls, mesh): pass - def edit_active_space_obj_from_mesh(cls, mesh): pass - def set_obj_origin_to_bboxcenter(cls, obj): pass - def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj): pass + def get_2d_vertices_from_polygon(cls, poly, obj, polygon_is_si=True): pass + def set_extrusion_representation_from_polygon(cls, obj, element, poly, depth_ifc, polygon_is_si=True): pass + def set_space_representation_from_polygon(cls, obj, element, poly, h, polygon_is_si=True): pass + def set_covering_representation_from_polygon(cls, obj, poly, polygon_is_si=True): pass + def create_object(cls, name): pass + def set_obj_origin_to_polygon_center(cls, obj, poly, polygon_is_si=True): pass def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj): pass def get_selected_objects(cls): pass def get_active_obj(cls): pass @@ -1007,14 +1066,9 @@ class Spatial: def get_active_obj_height(cls): pass def get_relating_type_id(cls): pass def translate_obj_to_z_location(cls, obj, z): pass - def get_2d_vertices_from_obj(cls, obj): pass - def get_scaled_2d_vertices(cls, points): pass - def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass - def get_body_representation(cls, obj): pass def assign_ifcspace_class_to_obj(cls, obj): pass def assign_type_to_obj(cls, obj): pass def assign_relating_type_to_element(cls, ifc, type, element, relating_type): pass - def regen_obj_representation(cls, obj, body): pass def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass def toggle_hide_spaces(cls, spaces): pass def set_default_container(cls, container): pass @@ -1119,6 +1173,8 @@ class Type: def get_representation_context(cls, representation): pass def get_type_occurrences(cls, element_type): pass def has_material_usage(cls, element): pass + def record_material_usage_attributes(cls, element): pass + def restore_material_usage_attributes(cls, element, usage_attributes): pass def run_geometry_add_representation(cls, obj=None, context=None, ifc_representation_class=None, profile_set_usage=None): pass def run_geometry_switch_representation(cls, obj=None, representation=None): pass diff --git a/src/bonsai/bonsai/tool/aggregate.py b/src/bonsai/bonsai/tool/aggregate.py index 43b155a5aa..1f7f601862 100644 --- a/src/bonsai/bonsai/tool/aggregate.py +++ b/src/bonsai/bonsai/tool/aggregate.py @@ -49,21 +49,40 @@ class Aggregate(bonsai.core.tool.Aggregate): related_object = tool.Ifc.get_entity(related_obj) if not relating_object or not related_object: return False + if relating_object == related_object: + return False + + is_compatible_class = False if (relating_object.is_a("IfcElement") or relating_object.is_a("IfcElementType")) and related_object.is_a( "IfcElement" ): - return True - if tool.Ifc.get_schema() == "IFC2X3": + is_compatible_class = True + elif tool.Ifc.get_schema() == "IFC2X3": if relating_object.is_a("IfcSpatialStructureElement") and related_object.is_a("IfcSpatialStructureElement"): - return True - if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"): - return True + is_compatible_class = True + elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialStructureElement"): + is_compatible_class = True else: if relating_object.is_a("IfcSpatialElement") and related_object.is_a("IfcSpatialElement"): - return True - if relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"): - return True - return False + is_compatible_class = True + elif relating_object.is_a("IfcProject") and related_object.is_a("IfcSpatialElement"): + is_compatible_class = True + + if not is_compatible_class: + return False + + # Prevent cyclic references: walk up the full hierarchy from the + # proposed parent and reject if we encounter the proposed child. + ancestor = ifcopenshell.util.element.get_parent(relating_object) + seen = {relating_object} + while ancestor: + if ancestor == related_object: + return False + if ancestor in seen: + break + seen.add(ancestor) + ancestor = ifcopenshell.util.element.get_parent(ancestor) + return True @classmethod def has_physical_body_representation(cls, element: ifcopenshell.entity_instance) -> bool: diff --git a/src/bonsai/bonsai/tool/bsdd.py b/src/bonsai/bonsai/tool/bsdd.py index 74d9926f15..387474b81f 100644 --- a/src/bonsai/bonsai/tool/bsdd.py +++ b/src/bonsai/bonsai/tool/bsdd.py @@ -32,6 +32,8 @@ import bonsai.core.tool import bonsai.tool as tool if TYPE_CHECKING: + from bsdd.bsdd import ClassContractV1, ClassPropertyContractV1, PropertyContractV5 + from bonsai.bim.module.bsdd.prop import BIMBSDDProperties, BSDDDictionary @@ -39,8 +41,8 @@ class Bsdd(bonsai.core.tool.Bsdd): default_identifier_url = "https://identifier.buildingsmart.org" default_api_url = "https://api.bsdd.buildingsmart.org/api/" client = bsdd.Client() - bsdd_classes: dict[str, dict] = {} - bsdd_properties: dict[str, dict] = {} + bsdd_classes: dict[str, ClassContractV1] = {} + bsdd_properties: dict[str, ClassPropertyContractV1 | PropertyContractV5] = {} @classmethod def identifier_url(cls) -> str: @@ -267,7 +269,8 @@ class Bsdd(bonsai.core.tool.Bsdd): @classmethod def get_bsdd_property(cls, uri: str) -> dict: if not (bsdd_property := cls.bsdd_properties.get(uri, {})): - bsdd_property = cls.client.get_property(uri, include_classes=True) + # Cache miss occurs for keyword search mode, for classes cache is prepopulated. + bsdd_property = cls.client.get_property(uri) cls.bsdd_properties[uri] = bsdd_property return bsdd_property diff --git a/src/bonsai/bonsai/tool/cost.py b/src/bonsai/bonsai/tool/cost.py index 663b16040e..fc07a629a6 100644 --- a/src/bonsai/bonsai/tool/cost.py +++ b/src/bonsai/bonsai/tool/cost.py @@ -154,7 +154,7 @@ class Cost(bonsai.core.tool.Cost): device = aud.Device() # chaching.mp3 is by Lucish_ CC-BY-3.0 https://freesound.org/people/Lucish_/sounds/554841/ filepath = tool.Blender.get_data_dir_path("chaching.mp3").__str__() - sound = aud.Sound(filepath) + sound = aud.Sound(filepath) # ty:ignore[too-many-positional-arguments] device.play(sound) @classmethod @@ -987,7 +987,8 @@ class Cost(bonsai.core.tool.Cost): def disable_editing_cost_item_parent(cls) -> None: props = cls.get_cost_props() props.active_cost_item_id = 0 - props.change_cost_item_parent = False + if props.change_cost_item_parent == True: + props.change_cost_item_parent = False @classmethod def load_cost_item_quantities(cls, cost_item: Optional[ifcopenshell.entity_instance] = None) -> None: diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 00bb96a267..d4b9336c2a 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -870,6 +870,8 @@ class Drawing(bonsai.core.tool.Drawing): @classmethod def edit_text_literals(cls, obj: bpy.types.Object, literal_attributes: dict) -> None: + if not literal_attributes: + return assert (element := tool.Ifc.get_entity(obj)) assert (rep := cls.get_annotation_representation(element)) to_remove = [i for i in rep.Items if i.is_a("IfcTextLiteral")] @@ -1301,6 +1303,12 @@ class Drawing(bonsai.core.tool.Drawing): def get_representation(cls, element, context): return ifcopenshell.util.representation.get_representation(element, context) + @classmethod + def set_camera_name(cls, drawing: ifcopenshell.entity_instance, name: str) -> None: + camera = tool.Ifc.get_object(drawing) + if camera and camera.name != name: + camera.name = name + @classmethod def set_drawing_collection_name( cls, drawing: ifcopenshell.entity_instance, collection: bpy.types.Collection @@ -1769,6 +1777,10 @@ class Drawing(bonsai.core.tool.Drawing): # For section/elevation views, elevate the segment vertically if not (points := helper.elevate_segment(bounds, [v1, v2])): return + elif target_view == "MODEL_VIEW": + # For model views, clip to XY bounds and keep Z (3D line at true elevation) + if not (points := helper.clip_segment(bounds, [v1, v2])): + return else: return diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index e24152d642..0d690d0308 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1407,8 +1407,6 @@ class Geometry(bonsai.core.tool.Geometry): :param representation_item: item to remove. :param element: item's element. Is used to unmark manual booleans. """ - # NOTE: we assume it's not the last representation item - # otherwise we probably would need to remove representation too # NOTE: a lot of shared code with `geometry.remove_representation` ifc_file = tool.Ifc.get() shape_aspects: list[ifcopenshell.entity_instance] = [] @@ -1467,7 +1465,10 @@ class Geometry(bonsai.core.tool.Geometry): cls.remove_representation_items_from_shape_aspect([representation_item], shape_aspect) if representation: - representation.Items = tuple(set(representation.Items) - {representation_item}) + new_items = tuple(set(representation.Items) - {representation_item}) + if not new_items: + return + representation.Items = new_items also_consider = list(consider_inverses) ifcopenshell.util.element.remove_deep2(ifc_file, representation_item, also_consider=also_consider) diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index 8cbd2a112b..6d79ce7582 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -197,12 +197,16 @@ class Ifc(bonsai.core.tool.Ifc): if not cls.get(): return + # Clear all per-object msgbus subscriptions at once using the dedicated + # owner. After undo/redo, per-object Python wrappers have new + # identities so clearing by individual obj would miss stale + # subscriptions registered with the old wrappers. + bpy.msgbus.clear_by_owner(bonsai.bim.handler.object_subscription_owner) + for obj in bpy.data.objects: if obj.library: continue - bpy.msgbus.clear_by_owner(obj) - element = cls.get_entity(obj) if not element: continue @@ -217,8 +221,6 @@ class Ifc(bonsai.core.tool.Ifc): if obj.library: continue - bpy.msgbus.clear_by_owner(obj) - style = cls.get_entity(obj) if not style: continue diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index db557542bc..49e6440bae 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -18,13 +18,14 @@ from __future__ import annotations +import json import logging import os import re import subprocess import tempfile from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, Union +from typing import TYPE_CHECKING, Any, Union import bpy @@ -128,39 +129,27 @@ class IfcGit: cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) repo.index.commit(message="Added " + os.path.relpath(path_file, repo.working_dir)) - bpy.ops.ifcgit.refresh() @classmethod def git_checkout(cls, path_file: str) -> None: IfcGitRepo.repo.git.checkout(path_file) @classmethod - def checkout_new_branch(cls, path_file: str) -> None: + def checkout_new_branch(cls, path_file: str, branch_name: str) -> None: """Create a branch and move uncommitted changes to this branch""" - props = cls.get_ifcgit_props() - if props.new_branch_name: - IfcGitRepo.repo.git.checkout(b=props.new_branch_name) - props.display_branch = props.new_branch_name - props.new_branch_name = "" - bpy.ops.ifcgit.refresh() + IfcGitRepo.repo.git.checkout(b=branch_name) @classmethod - def git_commit(cls, path_file: str) -> None: - props = cls.get_ifcgit_props() + def git_commit(cls, path_file: str, commit_message: str) -> None: repo = IfcGitRepo.repo if os.name == "nt": cls.dos2unix(path_file) repo.index.add(os.path.normpath(path_file)) - repo.index.commit(message=props.commit_message) - props.commit_message = "" + repo.index.commit(message=commit_message) @classmethod - def add_tag(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - item = props.ifcgit_commits[props.commit_index] - repo.create_tag(props.new_tag_name, ref=item.hexsha, message=props.new_tag_message) - props.new_tag_name = "" - props.new_tag_message = "" + def add_tag(cls, repo: git.Repo, hexsha: str, tag_name: str, tag_message: str = "") -> None: + repo.create_tag(tag_name, ref=hexsha, message=tag_message) @classmethod def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None: @@ -168,20 +157,17 @@ class IfcGit: repo.delete_tag(tag_name) @classmethod - def add_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - repo.create_remote(name=props.remote_name, url=props.remote_url) - props.remote_name = "" - props.remote_url = "" + def rename_branch(cls, repo: git.Repo, new_name: str) -> None: + repo.active_branch.rename(new_name) @classmethod - def delete_remote(cls, repo: git.Repo) -> None: - props = cls.get_ifcgit_props() - remote_name = props.select_remote + def add_remote(cls, repo: git.Repo, remote_name: str, remote_url: str) -> None: + repo.create_remote(name=remote_name, url=remote_url) + + @classmethod + def delete_remote(cls, repo: git.Repo, remote_name: str) -> None: if remote_name in repo.remotes: repo.delete_remote(remote_name) - if repo.remotes: - props.select_remote = repo.remotes[0].name @classmethod def push(cls, repo: git.Repo, remote_name: str, branch_name: str) -> Union[str, None]: @@ -193,16 +179,25 @@ class IfcGit: return exc.stderr @classmethod - def create_new_branch(cls) -> None: - """Convert a detached HEAD into a branch""" - props = cls.get_ifcgit_props() - repo = IfcGitRepo.repo - new_branch = repo.create_head(props.new_branch_name) - new_branch.checkout() - props.display_branch = props.new_branch_name - props.new_branch_name = "" + def is_head_detached(cls) -> bool: + return bool(IfcGitRepo.repo.head.is_detached) - bpy.ops.ifcgit.refresh() + @classmethod + def repo_has_commits(cls) -> bool: + if IfcGitRepo.repo: + return bool(IfcGitRepo.repo.heads) + return False + + @classmethod + def get_active_branch_name(cls) -> str: + return IfcGitRepo.repo.active_branch.name + + @classmethod + def create_new_branch(cls, branch_name: str) -> None: + """Convert a detached HEAD into a branch""" + repo = IfcGitRepo.repo + new_branch = repo.create_head(branch_name) + new_branch.checkout() @classmethod def clear_commits_list(cls) -> None: @@ -222,7 +217,7 @@ class IfcGit: rev=[props.display_branch], ) ) - commits_relevant = list( + commits_relevant = set( git.objects.commit.Commit.iter_items( repo=repo, rev=[props.display_branch], @@ -230,11 +225,17 @@ class IfcGit: ) ) + def is_relevant(commit): + if commit in commits_relevant: + return True + # Merge commits are relevant too + return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents) + for commit in commits: if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup: continue - elif props.ifcgit_filter == "relevant" and commit not in commits_relevant: + elif props.ifcgit_filter == "relevant" and not is_relevant(commit): continue props.ifcgit_commits.add() @@ -243,7 +244,8 @@ class IfcGit: list_item.message = commit.message list_item.author_name = commit.author.name list_item.author_email = commit.author.email - if commit in commits_relevant: + list_item.committed_date = int(commit.committed_date) + if is_relevant(commit): list_item.relevant = True if commit.hexsha in lookup: for tag in lookup[commit.hexsha]: @@ -286,14 +288,23 @@ class IfcGit: bpy.data.orphans_purge(do_recursive=True) + import bonsai.bim.handler + from bonsai.bim.module.model.data import AuthoringData + from bonsai.bim.module.root.data import IfcClassData + + AuthoringData.type_thumbnails = {} + + IfcClassData.is_loaded = False + settings = import_ifc.IfcImportSettings.factory(bpy.context, path_ifc, logging.getLogger("ImportIFC")) settings.should_setup_viewport_camera = False ifc_importer = import_ifc.IfcImporter(settings) ifc_importer.execute() - tool.Project.load_project_pset_templates() tool.Project.load_default_thumbnails() tool.Project.set_default_context() tool.Project.set_default_modeling_dimensions() + tool.Root.reload_grid_decorator() + bonsai.bim.handler.refresh_ui_data() bpy.ops.object.select_all(action="DESELECT") @classmethod @@ -393,20 +404,43 @@ class IfcGit: model = tool.Ifc.get() modified_step_ids = {"modified": set()} - for step_id in step_ids["modified"] | step_ids["added"]: - try: - entity = model.by_id(step_id) - except: - continue - if entity.is_a("IfcProductDefinitionShape"): + def collect(entity, depth=0): + if depth > 2: + return + if entity.is_a("IfcProduct"): + modified_step_ids["modified"].add(entity.id()) + elif entity.is_a("IfcProductDefinitionShape"): for product in entity.ShapeOfProduct: modified_step_ids["modified"].add(product.id()) elif entity.is_a("IfcObjectPlacement"): for product in entity.PlacesObject: modified_step_ids["modified"].add(product.id()) - elif entity.is_a("IfcTypeProduct") and entity.Types: - for related_object in entity.Types[0].RelatedObjects: - modified_step_ids["modified"].add(related_object.id()) + elif entity.is_a("IfcTypeProduct"): + for rel in entity.Types: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcShapeRepresentation"): + for prod_rep in entity.OfProductRepresentation: + for product in prod_rep.ShapeOfProduct: + modified_step_ids["modified"].add(product.id()) + elif entity.is_a("IfcRepresentationItem"): + for referencing in model.get_inverse(entity): + if referencing.is_a("IfcShapeRepresentation"): + collect(referencing, depth + 1) + elif entity.is_a("IfcPropertySet"): + for rel in entity.DefinesOccurrence: + for obj in rel.RelatedObjects: + modified_step_ids["modified"].add(obj.id()) + elif entity.is_a("IfcProperty"): + for pset in entity.PartOfPset: + collect(pset, depth + 1) + + for step_id in step_ids["modified"] | step_ids["added"]: + try: + entity = model.by_id(step_id) + except: + continue + collect(entity) return modified_step_ids @@ -458,38 +492,56 @@ class IfcGit: if item.hexsha in lookup: for branch in lookup[item.hexsha]: if branch.name == props.display_branch: + if isinstance(branch, git.RemoteReference): + # Checking out a remote branch tip goes to detached HEAD. + # Pre-fill the new branch name field with the local equivalent + # so the user isn't blocked from committing without a hint. + local_name = branch.remote_head + props.new_branch_name = cls._unique_branch_name(repo, local_name) branch.checkout() return # NOTE this is calling the git binary in a subprocess repo.git.checkout(item.hexsha) + @classmethod + def _unique_branch_name(cls, repo: git.Repo, name: str) -> str: + """Return name if unused, otherwise name-2, name-3, etc.""" + existing = {h.name for h in repo.heads} + if name not in existing: + return name + i = 2 + while f"{name}-{i}" in existing: + i += 1 + return f"{name}-{i}" + @classmethod def delete_collection(cls, blender_collection: bpy.types.Collection) -> None: for obj in blender_collection.objects: bpy.data.objects.remove(obj, do_unlink=True) bpy.data.collections.remove(blender_collection) - @classmethod - def is_valid_branch_name(cls, new_branch_name: str): - """Check if a branch name is valid and doesn't conflict with existing branches""" - if not cls.is_valid_ref_format(new_branch_name): - return False - if new_branch_name in [branch.name for branch in IfcGitRepo.repo.branches]: - return False - return True - @classmethod def config_ifcmerge(cls) -> None: config_reader = IfcGitRepo.repo.config_reader() section = 'mergetool "ifcmerge"' + new_cmd = "ifcmerge $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") != new_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) section = 'mergetool "ifcmerge-forward"' + new_cmd = "ifcmerge --prioritise-local $BASE $LOCAL $REMOTE $MERGED > $MERGED.ifcmerge" if not config_reader.has_section(section): with IfcGitRepo.repo.config_writer() as config_writer: - config_writer.set_value(section, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED") + config_writer.set_value(section, "cmd", new_cmd) + config_writer.set_value(section, "trustExitCode", True) + elif config_reader.get_value(section, "cmd") != new_cmd: + with IfcGitRepo.repo.config_writer() as config_writer: + config_writer.set_value(section, "cmd", new_cmd) config_writer.set_value(section, "trustExitCode", True) @classmethod @@ -519,49 +571,117 @@ class IfcGit: output.write(line + b"\n") @classmethod - def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]: + def get_selected_branch(cls) -> Union[str, None]: + """Return the name of the branch at the selected commit matching display_branch, or None.""" props = cls.get_ifcgit_props() repo = IfcGitRepo.repo item = props.ifcgit_commits[props.commit_index] lookup = cls.branches_by_hexsha(repo) - if item.hexsha in lookup: - for branch in lookup[item.hexsha]: - if branch.name == props.display_branch: - # this is a branch! - if re.match("^(origin/)?(HEAD|main|master)$", branch.name): - # preserve remote IDs in origin/main or main - mergetool = "ifcmerge" - else: - # rewrite remote IDs - mergetool = "ifcmerge-forward" - try: - # NOTE this is calling the git binary in a subprocess - repo.git.merge(branch) - except git.exc.GitCommandError: - # merge is expected to fail, run ifcmerge - try: - repo.git.mergetool(tool=mergetool) - except git.exc.GitCommandError as exc: - message = re.sub("( stderr: '|')", "", exc.stderr) - # ifcmerge failed, rollback - repo.git.merge(abort=True) + if item.hexsha not in lookup: + return None + for branch in lookup[item.hexsha]: + if branch.name == props.display_branch: + return branch.name + return None - operator.report({"ERROR"}, "IFC Merge failed:" + message) - return False - else: - if os.name == "nt": - cls.dos2unix(path_ifc) - repo.index.add(os.path.normpath(path_ifc)) - repo.git.commit("--no-edit") - except git.exc.GitError: - operator.report({"ERROR"}, "Unknown IFC Merge failure") - return False + @classmethod + def get_merge_tool(cls, branch_name: str) -> str: + if re.match("^(origin/)?(HEAD|main|master)$", branch_name): + return "ifcmerge" + return "ifcmerge-forward" - props.display_branch = repo.active_branch.name + @classmethod + def git_merge(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge. Returns None on clean merge, 'conflict' on expected + GitCommandError, or 'error' on an unknown GitError.""" + repo = IfcGitRepo.repo + branch = repo.refs[branch_name] + try: + repo.git.merge(branch) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + return "error" - cls.load_project(path_ifc) - cls.refresh_revision_list(path_ifc) - cls.decolourise() + @classmethod + def git_merge_no_commit(cls, branch_name: str) -> Union[str, None]: + """Attempt a git merge without committing (always leaves a merge state to abort). + Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" + repo = IfcGitRepo.repo + branch = repo.refs[branch_name] + try: + repo.git.merge(branch, no_commit=True, no_ff=True) + return None + except git.exc.GitCommandError: + return "conflict" + except git.exc.GitError: + return "error" + + @classmethod + def git_mergetool(cls, mergetool: str, path_ifc: str) -> Union[list, None]: + """Run ifcmerge tool. Returns None on success, list of conflict dicts on failure.""" + repo = IfcGitRepo.repo + report_path = path_ifc + ".ifcmerge" + try: + repo.git.mergetool(tool=mergetool) + except git.exc.GitCommandError as e: + print(f"ifcgit: mergetool failed: {e}") + + conflicts = None + if os.path.exists(report_path): + try: + with open(report_path) as f: + content = f.read().strip() + if content: + data = json.loads(content) + conflicts = data.get("conflicts", []) + except (json.JSONDecodeError, OSError): + pass + try: + os.remove(report_path) + except OSError: + pass + + if conflicts is None and repo.index.unmerged_blobs(): + conflicts = [] + + return conflicts + + @classmethod + def store_merge_conflicts(cls, conflicts: list) -> None: + cls.get_ifcgit_props().merge_conflicts = json.dumps(conflicts) + + @classmethod + def clear_merge_conflicts(cls) -> None: + cls.get_ifcgit_props().merge_conflicts = "" + + @classmethod + def get_merge_conflicts(cls) -> Union[list, None]: + raw = cls.get_ifcgit_props().merge_conflicts + if not raw: + return None + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + + @classmethod + def git_merge_abort(cls) -> None: + IfcGitRepo.repo.git.merge(abort=True) + + @classmethod + def commit_merge(cls, path_ifc: str) -> None: + repo = IfcGitRepo.repo + if os.name == "nt": + cls.dos2unix(path_ifc) + repo.index.add(os.path.normpath(path_ifc)) + repo.git.commit("--no-edit") + + @classmethod + def set_display_branch(cls) -> None: + props = cls.get_ifcgit_props() + props.display_branch = IfcGitRepo.repo.active_branch.name @classmethod def entity_log(cls, path_ifc: str, step_id: int) -> str: @@ -589,6 +709,18 @@ class IfcGit: except FileNotFoundError: operator.report({"ERROR"}, "Winget is not available. Make sure Windows Package Manager is installed.") + @classmethod + def select_first_remote(cls) -> None: + props = cls.get_ifcgit_props() + repo = IfcGitRepo.repo + if repo and repo.remotes: + props.select_remote = repo.remotes[0].name + + @classmethod + def fetch(cls, remote_name: str) -> None: + repo = IfcGitRepo.repo + repo.remotes[remote_name].fetch() + @classmethod def run_git_diff(cls, operator: bpy.types.Operator, save_to_temp: bool) -> None: path = tool.Ifc.get_path() diff --git a/src/bonsai/bonsai/tool/nest.py b/src/bonsai/bonsai/tool/nest.py index 37d0fa678f..5a1b2c83b2 100644 --- a/src/bonsai/bonsai/tool/nest.py +++ b/src/bonsai/bonsai/tool/nest.py @@ -45,9 +45,23 @@ class Nest(bonsai.core.tool.Nest): related_object = tool.Ifc.get_entity(related_obj) if not relating_object or not related_object: return False - if relating_object.is_a("IfcElement") and related_object.is_a("IfcElement"): - return True - return False + if relating_object == related_object: + return False + is_compatible_class = relating_object.is_a("IfcElement") and related_object.is_a("IfcElement") + if not is_compatible_class: + return False + # Prevent cyclic references: walk up the full hierarchy from the + # proposed parent and reject if we encounter the proposed child. + ancestor = ifcopenshell.util.element.get_parent(relating_object) + seen = {relating_object} + while ancestor: + if ancestor == related_object: + return False + if ancestor in seen: + break + seen.add(ancestor) + ancestor = ifcopenshell.util.element.get_parent(ancestor) + return True @classmethod def disable_editing(cls, obj: bpy.types.Object) -> None: diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index d98aaf26ab..95f2cbc92e 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -16,12 +16,16 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations + +import math from typing import Union import bmesh import bpy import mathutils import numpy as np +from bpy_extras import view3d_utils from mathutils import Vector import bonsai.core.tool @@ -41,6 +45,7 @@ class Raycast(bonsai.core.tool.Raycast): (0, -offset), (offset, -offset), ) + snap_objs = [] @classmethod def get_visible_objects(cls, context: bpy.types.Context): @@ -69,8 +74,10 @@ class Raycast(bonsai.core.tool.Raycast): rv3d = context.region_data assert rv3d view_location = rv3d.view_matrix.inverted().translation + view_normal = rv3d.view_rotation @ mathutils.Vector((0.0, 0.0, -1.0)) obj_matrix = obj.matrix_world.copy() bbox = [obj_matrix @ Vector(v) for v in obj.bound_box] + bbox_edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)] transposed_bbox: list[Vector] = [] bbox_2d: list[float] = [] @@ -94,8 +101,25 @@ class Raycast(bonsai.core.tool.Raycast): for v in bbox: coord_2d = tool.Cad.location_3d_to_region_2d_np(context.region, context.space_data.region_3d, v) - if coord_2d is not None: - transposed_bbox.append(coord_2d) + transposed_bbox.append(coord_2d) + + if not any(transposed_bbox): + transposed_bbox = [] + # If there are None values in transposed_bbox it means that there are vertices behind the camera + # so we get the intersection of the edge with the region border + # new_bbox = [] + if any(transposed_bbox) and not all(transposed_bbox): + new_bbox = transposed_bbox.copy() + new_bbox = [x for x in new_bbox if x is not None] + for edge in bbox_edges: + if (transposed_bbox[edge[0]] is None) ^ (transposed_bbox[edge[1]] is None): + point, _ = cls.intersect_edge_region_border( + context.region, context.space_data, rv3d, bbox[edge[0]], bbox[edge[1]] + ) + if point: + new_bbox.append(point) + if new_bbox: + transposed_bbox = new_bbox region = context.region borders = (0, region.width, 0, region.height) @@ -117,6 +141,96 @@ class Raycast(bonsai.core.tool.Raycast): return (obj, bbox_2d) return None + def intersect_edge_region_border(region, space, rv3d, v1, v2): + def segment_intersect_near_plane(view_matrix, clip_start, p_world_a, p_world_b): + a_view = view_matrix @ p_world_a + b_view = view_matrix @ p_world_b + z_near = -clip_start + za = a_view.z + zb = b_view.z + denom = zb - za + if denom == 0.0: + return None, None + t = (z_near - za) / denom + if t < 0.0 or t > 1.0: + return None, None + p_view = a_view.lerp(b_view, t) + cam_world = view_matrix.inverted() + p_world = cam_world @ p_view + return p_world, t + + def is_inside_region(pt2d, region): + return 0.0 <= pt2d.x <= region.width and 0.0 <= pt2d.y <= region.height + + def clamp_to_region_border(point2d, region): + x, y = point2d + x_clamped = max(0.0, min(region.width, x)) + y_clamped = max(0.0, min(region.height, y)) + return Vector((x_clamped, y_clamped)) + + def find_nearby_onscreen_point(region, rv3d, p1, p2, initial_t_on_segment, max_iters=40, step=0.05): + """ + Use iterative approach: move t toward 0. Returns the first point that is inside region border + """ + t = initial_t_on_segment + for i in range(max_iters): + test_3d = p1.lerp(p2, t) + test_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, test_3d) + if test_2d is not None and is_inside_region(test_2d, region): + return test_3d, test_2d, t + # move t toward 0 by reducing it by a fraction of its current value + t -= step + # if t is already very small, break + if t <= 1e-6: + break + + return None, None, None + + # Ensures that all the calculation uses the same direction based on which point is on the screen + if view3d_utils.location_3d_to_region_2d(region, rv3d, v1): + onscreen_vert = v1 + offscreen_vert = v2 + else: + onscreen_vert = v2 + offscreen_vert = v1 + # v2, v1 = v1, v2 + + clip_start = space.clip_start + view_mat = rv3d.view_matrix + inter_world, t_on_ab = segment_intersect_near_plane(view_mat, clip_start, onscreen_vert, offscreen_vert) + + if inter_world is None: + print("No intersection with viewport near plane found for the segment.") + return None, None + + init_2d = view3d_utils.location_3d_to_region_2d(region, rv3d, inter_world) + + if init_2d is not None and is_inside_region(init_2d, region): + final_world = inter_world + final_2d = init_2d + final_t = t_on_ab + else: + found_world, found_2d, found_t = find_nearby_onscreen_point( + region, rv3d, onscreen_vert, offscreen_vert, t_on_ab, max_iters=600, step=0.01 + ) + if found_world is None: + if init_2d is None: + print("Initial projection invalid and iterative search failed.") + return None, None + # fallback: clamp projected point to border via manual mapping + final_2d = clamp_to_region_border(init_2d, region) + final_world = None + final_t = None + # print("Iterative search failed; using clamped 2D:", final_2d) + else: + final_world = found_world + final_2d = found_2d + final_t = found_t + # print(f"Found onscreen point at t={final_t:.4f}") + + # print("Final 2D:", final_2d) + return final_2d, v2 + @classmethod def intersect_mouse_2d_bounding_box(cls, mouse_pos: tuple[int, int], bbox: list[float, float, float, float]): x, y = mouse_pos @@ -232,6 +346,158 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def ray_cast_by_proximity_2d( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + snap_obj: SnapObj, + ): + + def divide_vector(start, end, n): + points = [] + delta = (end - start) / n + for i in range(1, n): + point = start + i * delta + points.append(point) + return points + + region = context.region + rv3d = context.region_data + mouse_pos = event.mouse_region_x, event.mouse_region_y + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + points = [] + + try: + loc = tool.Cad.region_2d_to_location_3d_np(region, rv3d, mouse_pos, ray_direction) + except: + loc = Vector((0, 0, 0)) + + verts_2d = [ + view3d_utils.location_3d_to_region_2d(region, rv3d, v) for v in snap_obj.verts_3d + ] # Numpy version is worst in performance + + intersected = snap_obj.raycast_boxes( + context, event, snap_obj.root, intersected=[], rays=(ray_origin, ray_direction) + ) + edges = [] + for it in intersected: + edges.extend(it.edges) + edges = set(edges) + + edge_verts = {} + for e in edges: + verts_idx = tuple(snap_obj.obj.data.edges[e].vertices) + verts = snap_obj.obj.data.vertices + v1 = snap_obj.obj.matrix_world @ verts[verts_idx[0]].co + v1_2d = verts_2d[verts_idx[0]] + v2 = snap_obj.obj.matrix_world @ verts[verts_idx[1]].co + v2_2d = verts_2d[verts_idx[1]] + if (v1_2d is None) ^ (v2_2d is None): + point, _ = cls.intersect_edge_region_border(region, context.space_data, rv3d, v1, v2) + if v1_2d is None: + edge_verts[e] = (point, v2_2d) + else: + edge_verts[e] = (v1_2d, point) + else: + edge_verts[e] = (v1_2d, v2_2d) + + snap_threshold = 10.0 + + for i, point in enumerate(verts_2d): + if not point: + continue + distance = (Vector(mouse_pos) - point).length + if distance <= snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Vertex", + "point": snap_obj.verts_3d[i], + "distance": distance / 10, + } + points.append(snap_point) + + count = 0 + selected_edges = {} + for e in edges: + p0, p1 = edge_verts[e] + p0x, p0y = p0 + p1x, p1y = p1 + px, py = mouse_pos + + # segment vector = p1 - p0 + sx = p1x - p0x + sy = p1y - p0y + + # seg length squared + seg_len_sq = sx * sx + sy * sy + + if seg_len_sq == 0.0: + # degenerate segment: skip it + continue + + # project (p - p0) onto seg: t = dot(p-p0, seg) / |seg|^2 + apx = px - p0x + apy = py - p0y + t = (apx * sx + apy * sy) / seg_len_sq + + # clamp to segment + if t <= 0.0: + t_clamped = 0.0 + cx, cy = p0x, p0y + elif t >= 1.0: + t_clamped = 1.0 + cx, cy = p1x, p1y + else: + t_clamped = t + cx = p0x + sx * t_clamped + cy = p0y + sy * t_clamped + + dx = px - cx + dy = py - cy + dist = math.hypot(dx, dy) + if dist <= snap_threshold: + selected_edges[dist] = e + + if selected_edges: + min_dist = float("inf") + for key in selected_edges: + if key < min_dist: + min_dist = key + + idx = snap_obj.obj.data.edges[selected_edges[min_dist]].vertices + edge_verts = (snap_obj.verts_3d[idx[0]], snap_obj.verts_3d[idx[1]]) + division_points = divide_vector( + edge_verts[0], edge_verts[1], 2 + ) # TODO Make it work for different divisions + for division_point in division_points: + intersection = tool.Cad.point_on_edge(division_point, (ray_target, loc)) + distance = (division_point - intersection).length + if distance < snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Edge Center", + "point": division_point.copy(), + "distance": distance, + } + points.append(snap_point) + + intersection = tool.Cad.intersect_edges_v2((ray_target, loc), edge_verts) + if intersection[0]: + if tool.Cad.is_point_on_edge(intersection[1], edge_verts): + distance = (intersection[1] - intersection[0]).length + if distance < snap_threshold: + snap_point = { + "object": snap_obj.obj, + "type": "Edge", + "point": intersection[1].copy(), + "edge_verts": edge_verts, + "distance": distance, + } + points.append(snap_point) + + return points + @classmethod def ray_cast_by_proximity( cls, @@ -457,7 +723,9 @@ class Raycast(bonsai.core.tool.Raycast): if bbox_2d: if tool.Raycast.intersect_mouse_2d_bounding_box(mouse_pos, bbox_2d): if tool.Raycast.object_is_visible_in_clipping_plane(obj): - objs_to_raycast.append(obj) + snap_obj = cls.create_snap_obj(obj) + if snap_obj is not None: + objs_to_raycast.append(snap_obj) return objs_to_raycast @@ -474,12 +742,6 @@ class Raycast(bonsai.core.tool.Raycast): face_index = None # Wireframes if obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0): - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, obj) - if snap_points: - hit = sorted(snap_points, key=lambda x: x["distance"])[0]["point"] - if hit: - hit_world = obj.original.matrix_world @ hit - return obj, hit_world, face_index return None, None, None # Meshes else: @@ -514,19 +776,20 @@ class Raycast(bonsai.core.tool.Raycast): ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) - for obj in objs_to_raycast: + for snap_obj in objs_to_raycast: if not include_wireframes and ( - obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0) + snap_obj.obj.type in {"EMPTY", "CURVE"} + or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0) ): continue - snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, obj) + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) if hit is not None: length_squared = (hit - ray_origin).length_squared if best_obj is None or length_squared < best_length_squared: best_length_squared = length_squared - best_obj = snap_obj + best_obj = hit_obj best_hit = hit best_face_index = face_index @@ -536,6 +799,79 @@ class Raycast(bonsai.core.tool.Raycast): else: return None, None, None + @classmethod + def ray_cast_and_get_closest_to_camera_snaps( + cls, + context: bpy.types.Context, + event: bpy.types.Event, + objs_to_raycast: list[bpy.types.Object], + ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]: + closest_length_squared = 1.0 + closest_obj = None + closest_hit = None + closest_face_index = None + + ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event) + + closest_snaps = [] + hit = None + + for snap_obj in objs_to_raycast: + if snap_obj.obj.type in {"EMPTY", "CURVE"} or ( + hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0 + ): + # For wireframe objects we have to test all the snaps to see which is closer + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + closest_wf_hit = None + closest_wf_length_squared = 1.0 + closest_wf_point = None + if snap_points: + for point in snap_points: + point["group"] = "Wireframe" + closest_snaps.append(point) + length = (point["point"] - ray_origin).length_squared + if closest_wf_hit is None or length < closest_wf_length_squared: + closest_wf_length_squared = length + closest_wf_hit = point["point"] + closest_wf_point = point + + if closest_wf_point: + hit_obj = closest_wf_point["object"] + hit = closest_wf_point["point"] + face_index = None + + else: + # Solid objects + hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj) + + if hit: + snap_point = { + "point": hit, + "type": "Face", + "group": "Object", + "object": hit_obj, + "face_index": face_index, + "distance": 9, # High value so it has low priority + } + closest_snaps.append(snap_point) + + # Here we test which is closer, including wireframe and solid objects + if hit is not None: + length_squared = (hit - ray_origin).length_squared + if closest_obj is None or length_squared < closest_length_squared: + closest_length_squared = length_squared + closest_obj = hit_obj + closest_hit = hit + closest_face_index = face_index + + # Label snaps from the closest object + if closest_obj is not None: + for snap in closest_snaps: + if snap["object"] == closest_obj: + snap["is_closest_to_camera"] = True + + return closest_snaps + @classmethod def calculate_snap_threshold(cls, view_distance): snap_threshold = view_distance / 100 @@ -547,3 +883,282 @@ class Raycast(bonsai.core.tool.Raycast): if lens < 50: snap_threshold *= value return snap_threshold + + @classmethod + def create_snap_obj(cls, obj): + if obj.data is None or not isinstance(obj.data, bpy.types.Mesh): + return None + for i, snap_obj in enumerate(cls.snap_objs): + if obj.name == snap_obj.obj.name: + # Fast O(1) invalidation: vertex count change (mesh edit) or + # world matrix change (object moved/rotated). + if len(obj.data.vertices) != len(snap_obj.verts_3d): + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + if obj.matrix_world != snap_obj.matrix_world: + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + # Sample one vertex to catch mesh edits that preserve vertex count. + if obj.data.vertices and snap_obj.verts_3d: + if (obj.matrix_world @ obj.data.vertices[0].co) != snap_obj.verts_3d[0]: + cls.snap_objs.pop(i) + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + return snap_obj + snap_obj = SnapObj(obj) + cls.snap_objs.append(snap_obj) + return snap_obj + + @classmethod + def clear_snap_objs(cls): + TreeNode.__clear_all__() + SnapObj.__clear_all__() + cls.snap_objs.clear() + + +class TreeNode: + all = [] + + def __init__(self, box: tuple): + self.__class__.all.append(self) + self.box = box + self.child_a = None + self.child_b = None + self.edges = [] + + def __clear_all__(): + for instance in TreeNode.all: + del instance + TreeNode.all.clear() + + +class SnapObj: + max_depth = 9 + all = [] + + def __init__(self, obj: bpy.types.Object): + self.__class__.all.append(self) + self.obj = obj + self.root = self._create_root_node() + self.root.edges = [e.index for e in obj.data.edges] + self.split_box(self.root, 0) + self.verts_3d = [obj.matrix_world @ v.co for v in obj.data.vertices] + self.matrix_world = obj.matrix_world.copy() + self.snap_points = [] + + def __clear_all__(): + for instance in SnapObj.all: + del instance + SnapObj.all.clear() + + def _create_root_node(self) -> TreeNode: + bbox = tool.Blender.get_object_bounding_box(self.obj) + min_point = self.obj.matrix_world @ bbox["min_point"] + max_point = self.obj.matrix_world @ bbox["max_point"] + new_bbox = self.expand_bounding_box((min_point, max_point)) + return TreeNode(new_bbox) + + def divide_bounding_box_along_longest_axis( + self, min_pt: Vector, max_pt: Vector + ) -> Union[tuple[Vector, Vector], tuple[Vector, Vector]]: + """ + Divide a bounding box into two equal parts along the axis with the longest dimension. + + Args: + min_pt: The minimum point of the bounding box. + max_pt: The maximum point of the bounding box. + + Returns: + list: A list of two tuples, each containing the minimum and maximum points of the divided boxes. + """ + + # Calculate the dimensions of the box + dx = max_pt.x - min_pt.x + dy = max_pt.y - min_pt.y + dz = max_pt.z - min_pt.z + + # Determine the axis with the longest dimension + if dx >= dy and dx >= dz: + # Divide along the x-axis + mid_x = min_pt.x + dx / 2 + box1 = (min_pt, Vector((mid_x, max_pt.y, max_pt.z))) + box2 = (Vector((mid_x, min_pt.y, min_pt.z)), max_pt) + elif dy >= dx and dy >= dz: + # Divide along the y-axis + mid_y = min_pt.y + dy / 2 + box1 = (min_pt, Vector((max_pt.x, mid_y, max_pt.z))) + box2 = (Vector((min_pt.x, mid_y, min_pt.z)), max_pt) + else: + # Divide along the z-axis + mid_z = min_pt.z + dz / 2 + box1 = (min_pt, Vector((max_pt.x, max_pt.y, mid_z))) + box2 = (Vector((min_pt.x, min_pt.y, mid_z)), max_pt) + + return [box1, box2] + + def expand_bounding_box(self, box: tuple[Vector, Vector], offset: float = 0.1) -> tuple[Vector, Vector]: + """ + Expand a 3D bounding box by a given offset. + + Args: + min_pt: The minimum point of the bounding box. + max_pt: The maximum point of the bounding box. + offset: The offset to expand the bounding box by. + + Returns: + tuple: A tuple containing the new minimum and maximum points of the expanded bounding box. + """ + + min_pt, max_pt = box + # Calculate the new minimum and maximum points + new_min_pt = Vector((min_pt.x - offset, min_pt.y - offset, min_pt.z - offset)) + new_max_pt = Vector((max_pt.x + offset, max_pt.y + offset, max_pt.z + offset)) + + return new_min_pt, new_max_pt + + def split_box(self, parent: TreeNode, depth: int): + """ + Splits the bounding box creating two child nodes to compose a BVH Tree recursively. + + Args: + parent: the TreeNode instance that represents the parent node of a BVH Tree. + depth: the depth of the BVH Tree no be used in recursion. + """ + if depth > self.max_depth: + return + box_a, box_b = self.divide_bounding_box_along_longest_axis(parent.box[0], parent.box[1]) + parent.child_a = TreeNode(box_a) + parent.child_b = TreeNode(box_b) + edges_a = [] + edges_b = [] + for e in parent.edges: + verts_idx = [v for v in self.obj.data.edges[e].vertices] + verts_coords = [] + for idx in verts_idx: + if idx < len(self.obj.data.vertices): + verts_coords.append(self.obj.matrix_world @ self.obj.data.vertices[idx].co) + if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_a.box): + edges_a.append(e) + if self.line_intersects_box(verts_coords[0], verts_coords[1], parent.child_b.box): + edges_b.append(e) + parent.child_a.edges = edges_a + parent.child_b.edges = edges_b + self.split_box(parent.child_a, depth + 1) + self.split_box(parent.child_b, depth + 1) + + def raycast_box( + self, context: bpy.types.Context, event: bpy.types.Event, node: TreeNode, rays: tuple[Vector, Vector] + ) -> bool: + """ + Raycast bounding box. + + Args: + context: Blender context. + event: Blender event. + node: a TreeNode instance. + rays: tuple containing ray origin and ray direction + + Returns: + True if hits the box or False otherwise. + """ + box = node.box + min_v = box[0] + max_v = box[1] + t_min = 0.0 + t_max = float("inf") + ray_origin, ray_dir = rays + inv_dir = Vector((1.0 / r if r != 0.0 else 1e32) for r in (ray_dir.x, ray_dir.y, ray_dir.z)) + # X + tx1 = (min_v.x - ray_origin.x) * inv_dir[0] + tx2 = (max_v.x - ray_origin.x) * inv_dir[0] + tmin = min(tx1, tx2) + tmax = max(tx1, tx2) + # Y + ty1 = (min_v.y - ray_origin.y) * inv_dir[1] + ty2 = (max_v.y - ray_origin.y) * inv_dir[1] + tmin = max(tmin, min(ty1, ty2)) + tmax = min(tmax, max(ty1, ty2)) + # Z + tz1 = (min_v.z - ray_origin.z) * inv_dir[2] + tz2 = (max_v.z - ray_origin.z) * inv_dir[2] + tmin = max(tmin, min(tz1, tz2)) + tmax = min(tmax, max(tz1, tz2)) + return (tmax >= max(tmin, t_min)) and (tmin <= t_max) + + def line_intersects_box(self, v1: mathutils.Vector, v2: mathutils.Vector, box: tuple) -> bool: + """ + Check if a line segment intersects an axis-aligned bounding box (AABB). + + Args: + v1: The first endpoint of the line segment as a mathutils.Vector. + v2: The second endpoint of the line segment as a mathutils.Vector. + box: A tuple containing the minimum and maximum points of the AABB, where each point is a mathutils.Vector. + + Returns: + bool: True if the segment [v1, v2] intersects the AABB; otherwise, False. + """ + bmin, bmax = box + dir = v2 - v1 + tmin = 0.0 + tmax = 1.0 + + for i in range(3): + if abs(dir[i]) < 1e-12: + # Line is parallel to slab. If origin not within slab -> no hit. + if v1[i] < bmin[i] or v1[i] > bmax[i]: + return False + else: + ood = 1.0 / dir[i] + t1 = (bmin[i] - v1[i]) * ood + t2 = (bmax[i] - v1[i]) * ood + if t1 > t2: + t1, t2 = t2, t1 + if t1 > tmin: + tmin = t1 + if t2 < tmax: + tmax = t2 + if tmin > tmax: + return False + + # If any overlap in [0,1] exists, there's intersection + return (tmax >= 0.0) and (tmin <= 1.0) + + def raycast_boxes( + self, + context: bpy.types.Context, + event: bpy.Types.Event, + node: TreeNode, + intersected: Union[TreeNode] = [], + rays: tuple[Vector, Vector] = (), + ) -> Union[TreeNode]: + """ + Raycast bounding box subdivisions recursively. + + Args: + context: Blender context. + event: Blender event. + node: a TreeNode instance. + intersected: list of intersected boxes to use in recursion. + rays: tuple containing ray origin and ray direction + + Returns: + tuple: a list of TreeNode instances that represent the subdivided boxes hit by the ray cast. + """ + if not node.child_a: + intersected.append(node) + return intersected + + intersects_a = self.raycast_box(context, event, node.child_a, rays) + intersects_b = self.raycast_box(context, event, node.child_b, rays) + if intersects_a: + intersected = self.raycast_boxes(context, event, node.child_a, intersected, rays) + + if intersects_b: + intersected = self.raycast_boxes(context, event, node.child_b, intersected, rays) + + return intersected diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index 02ddbe9745..8880a168fe 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -93,38 +93,16 @@ class Root(bonsai.core.tool.Root): elif dest.is_a("IfcTypeProduct"): if not source.RepresentationMaps: return copied_entities - - # Copy representation maps while preserving mapped representation structures - new_maps = [] - for i, rep_map in enumerate(source.RepresentationMaps): - source_rep = rep_map.MappedRepresentation - - # Copy the map itself - new_map = ifcopenshell.util.element.copy(tool.Ifc.get(), rep_map) - - # Handle the mapped representation - preserve mapping structure if present - if ( - source_rep.RepresentationType == "MappedRepresentation" - and len(source_rep.Items) == 1 - and source_rep.Items[0].is_a("IfcMappedItem") - ): - # This is a mapped representation - preserve the structure - new_rep = ifcopenshell.util.element.copy(tool.Ifc.get(), source_rep) - new_rep.Items = [ifcopenshell.util.element.copy(tool.Ifc.get(), item) for item in source_rep.Items] - new_map.MappedRepresentation = new_rep - else: - # Not a mapped representation - use copy_deep as before - new_map.MappedRepresentation = ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), - source_rep, - exclude=["IfcGeometricRepresentationContext"], - exclude_callback=exclude_callback, - copied_entities=copied_entities, - ) - - new_maps.append(new_map) - - dest.RepresentationMaps = new_maps + dest.RepresentationMaps = [ + ifcopenshell.util.element.copy_deep( + tool.Ifc.get(), + m, + exclude=["IfcGeometricRepresentationContext"], + exclude_callback=exclude_callback, + copied_entities=copied_entities, + ) + for m in source.RepresentationMaps + ] return copied_entities @classmethod diff --git a/src/bonsai/bonsai/tool/snap.py b/src/bonsai/bonsai/tool/snap.py index 02f484d388..5755c02ba7 100644 --- a/src/bonsai/bonsai/tool/snap.py +++ b/src/bonsai/bonsai/tool/snap.py @@ -388,57 +388,36 @@ class Snap(bonsai.core.tool.Snap): # Objects objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox) - # Wireframes - # For wireframe we have to get all the objects so we can further calculate edge intersection - for snap_obj in objs_to_raycast: - if snap_obj.type in {"EMPTY", "CURVE"} or (snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0): - snap_points = tool.Raycast.ray_cast_by_proximity(context, event, snap_obj) - if snap_points: - for point in snap_points: - point["group"] = "Wireframe" - detected_snaps.append(point) + closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast) + detected_snaps.extend(closest_snaps) - if (space.shading.type == "SOLID" and space.shading.show_xray) or ( + xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or ( space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe - ): - results = [] - for obj in objs_to_raycast: - results.append(tool.Raycast.cast_rays_to_single_object(context, event, obj)) - else: - results = [] - results.append(tool.Raycast.cast_rays_and_get_best_object(context, event, objs_to_raycast)) + ) - for result in results: - snap_obj = result[0] - hit = result[1] - face_index = result[2] - if hit is not None: - # Wireframes - if snap_obj.type in {"EMPTY", "CURVE"} or ( - snap_obj.type == "MESH" and len(snap_obj.data.polygons) == 0 - ): - continue - # Meshes - else: - # Add face snap - snap_point = { - "point": hit, - "type": "Face", - "group": "Object", - "object": snap_obj, - "face_index": face_index, - "distance": 9, # High value so it has low priority - } - detected_snaps.append(snap_point) - - # Add vertex and edge snap - snap_points = tool.Raycast.ray_cast_by_proximity( - context, event, snap_obj, snap_obj.data.polygons[face_index] - ) - if snap_points: - for point in snap_points: - point["group"] = "Object" - detected_snaps.append(point) + for snap_obj in objs_to_raycast: + for snap in closest_snaps: + if snap_obj.obj == snap["object"]: + if xray_mode: + if "face_index" in snap and snap["face_index"] is not None: + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + for point in snap_points: + point["group"] = "Object" + detected_snaps.append(point) + else: + # If it is a solid object that is closest to camera it ignores all the rest + if ( + "is_closest_to_camera" in snap + and snap["is_closest_to_camera"] + and snap["group"] == "Object" + ): + closest_snap = [snap] # discards objects that aren't the closest + if "face_index" in snap and snap["face_index"] is not None: + snap_points = tool.Raycast.ray_cast_by_proximity_2d(context, event, snap_obj) + for point in snap_points: + point["group"] = "Object" + closest_snap.append(point) + detected_snaps = closest_snap # snap to cut geometry (e.g. in plan view) if CutDecorator.installed: diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index b185b9ab59..11a41672bc 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -21,13 +21,13 @@ from __future__ import annotations import json from collections import defaultdict from collections.abc import Generator, Iterable -from math import pi from typing import TYPE_CHECKING, Any, Literal, Optional, Union import bmesh import bpy import ifcopenshell import ifcopenshell.api.attribute +import ifcopenshell.api.geometry import ifcopenshell.api.type import ifcopenshell.geom import ifcopenshell.util.classification @@ -991,114 +991,104 @@ class Spatial(bonsai.core.tool.Spatial): return poly @classmethod - def get_bmesh_from_polygon(cls, poly: Polygon, h: float, polygon_is_si: bool = False) -> bmesh.types.BMesh: - """ - :param h: Height, in meters. - :param polygon_is_si: Should be True if `poly` is defined in meters. - """ - mat = Matrix() - bm = bmesh.new() - bm.verts.index_update() - bm.edges.index_update() - - mat_invert = mat.inverted() - si_conversion = 1.0 if polygon_is_si else ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - new_verts = [ - # bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) for v in poly.exterior.coords[0:-1] - bm.verts.new(mat_invert @ (Vector([v[0], v[1], 0]) * si_conversion)) - for v in shapely.get_exterior_ring(poly).coords[0:-1] - ] - [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) - - bm.verts.index_update() - bm.edges.index_update() - - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) - bmesh.ops.triangle_fill(bm, edges=bm.edges) - bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) - - if h != 0: - extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] - bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) - - bmesh.ops.recalc_face_normals(bm, faces=bm.faces) - - return bm - - @classmethod - def get_named_obj_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Object: - mesh = cls.get_named_mesh_from_bmesh(name, bmesh) - obj = cls.get_named_obj_from_mesh(name, mesh) - return obj - - @classmethod - def get_named_obj_from_mesh(cls, name: str, mesh: bpy.types.Mesh) -> bpy.types.Object: + def create_object(cls, name: str) -> bpy.types.Object: + mesh = bpy.data.meshes.new(name=name) obj = bpy.data.objects.new(name, mesh) return obj @classmethod - def get_named_mesh_from_bmesh(cls, name: str, bmesh: bmesh.types.BMesh) -> bpy.types.Mesh: - mesh = bpy.data.meshes.new(name=name) - bmesh.to_mesh(mesh) - bmesh.free() - return mesh + def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + centroid = poly.centroid + if polygon_is_si: + obj.location = Vector((centroid.x, centroid.y, 0)) + else: + obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0)) @classmethod - def get_transformed_mesh_from_local_to_global(cls, mesh: bpy.types.Mesh) -> bpy.types.Mesh: - active_obj = cls.get_active_obj() - mat = active_obj.matrix_world - mesh.transform(mat.inverted()) - mesh.update() - return mesh + def get_2d_vertices_from_polygon( + cls, + poly: Polygon, + obj: bpy.types.Object, + polygon_is_si: bool = True, + ) -> list[list[float]]: + """Convert a world-space shapely polygon to 2D vertices in obj's local space, in IFC file units. + + :param poly: The polygon in world space. + :param obj: The Blender object whose local space is used. + :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units. + :return: List of [x, y] coordinates (not closed). + """ + ifc_file = tool.Ifc.get() + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) + bpy.context.view_layer.update() + mat_inv = obj.matrix_world.inverted() + coords_2d = [] + for v in shapely.get_exterior_ring(poly).coords[:-1]: + world_si = Vector((v[0], v[1], 0)) + if not polygon_is_si: + world_si = world_si * unit_scale + local_si = mat_inv @ world_si + coords_2d.append([local_si.x / unit_scale, local_si.y / unit_scale]) + return coords_2d @classmethod - def edit_active_space_obj_from_mesh(cls, mesh: bpy.types.Mesh) -> None: - active_obj = bpy.context.active_object - old_mesh = active_obj.data - old_mesh_name = old_mesh.name - assert active_obj and isinstance(old_mesh, bpy.types.Mesh) - tool.Geometry.get_mesh_props(mesh).ifc_definition_id = tool.Geometry.get_mesh_props(old_mesh).ifc_definition_id - tool.Geometry.change_object_data(active_obj, mesh, is_global=True) - tool.Ifc.edit(active_obj) - tool.Blender.remove_data_block(old_mesh) - # Rename after old mesh is removed to avoid .001 suffix. - mesh.name = old_mesh_name + def set_extrusion_representation_from_polygon( + cls, + obj: bpy.types.Object, + element: ifcopenshell.entity_instance, + poly: Polygon, + depth_ifc: float, + polygon_is_si: bool = True, + ) -> None: + """Create or replace the IFC body representation from a polygon extrusion. + + :param obj: The Blender object. + :param element: The IFC product entity. + :param poly: The polygon in world space. + :param depth_ifc: The extrusion depth in IFC file units. + :param polygon_is_si: True if polygon coords are in SI, False if in IFC file units. + """ + ifc_file = tool.Ifc.get() + builder = ifcopenshell.util.shape_builder.ShapeBuilder(ifc_file) + + coords_2d = cls.get_2d_vertices_from_polygon(poly, obj, polygon_is_si) + + curve = builder.polyline(coords_2d, closed=True) + item = builder.extrude(curve, magnitude=depth_ifc) + + old_body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + if old_body: + context = old_body.ContextOfItems + ifcopenshell.api.geometry.unassign_representation(ifc_file, product=element, representation=old_body) + ifcopenshell.api.geometry.remove_representation(ifc_file, representation=old_body) + else: + context = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + new_body = builder.get_representation(context, item) + ifcopenshell.api.geometry.assign_representation(ifc_file, product=element, representation=new_body) + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=new_body, + ) @classmethod - def set_obj_origin_to_bboxcenter(cls, obj: bpy.types.Object) -> None: - mat = obj.matrix_world - inverted = mat.inverted() - local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) - global_bbox_center = mat @ local_bbox_center + def set_space_representation_from_polygon( + cls, + obj: bpy.types.Object, + element: ifcopenshell.entity_instance, + poly: Polygon, + h: float, + polygon_is_si: bool = True, + ) -> None: + """Create or replace the IFC body representation of a space from a polygon. - oldLoc = obj.location - newLoc = global_bbox_center - diff = newLoc - oldLoc - for vert in obj.data.vertices: - aux_vector = mat @ vert.co - aux_vector = aux_vector - diff - vert.co = inverted @ aux_vector - obj.location = newLoc - - @classmethod - def set_obj_origin_to_bboxcenter_and_zero_elevation(cls, obj: bpy.types.Object) -> None: - mat = obj.matrix_world - inverted = mat.inverted() - local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) - global_bbox_center = mat @ local_bbox_center - global_obj_origin = global_bbox_center - global_obj_origin.z = 0 - - oldLoc = obj.location - newLoc = global_obj_origin - diff = newLoc - oldLoc - for vert in obj.data.vertices: - aux_vector = mat @ vert.co - aux_vector = aux_vector - diff - vert.co = inverted @ aux_vector - obj.location = newLoc + :param h: The height in SI (meters). + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + cls.set_extrusion_representation_from_polygon(obj, element, poly, h / unit_scale, polygon_is_si) @classmethod def set_obj_origin_to_cursor_position_and_zero_elevation(cls, obj: bpy.types.Object) -> None: @@ -1145,65 +1135,53 @@ class Spatial(bonsai.core.tool.Spatial): if z != 0: obj.location = obj.location + Vector((0, 0, z)) - @classmethod - def get_2d_vertices_from_obj(cls, obj: bpy.types.Object) -> list[tuple]: - points = [] - vectors = [v.co for v in obj.data.vertices.values()] - for vector in vectors: - points.append(vector.xy) - - points.append(vectors[0].xy) - return points - - @classmethod - def get_scaled_2d_vertices(cls, points: list[Vector]) -> list[tuple[float, float]]: - model = tool.Ifc.get() - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(model) - _points = [] - for p in points: - _p = list(p) - _p[0] /= unit_scale - _p[1] /= unit_scale - _points.append(_p) - return _points - - @classmethod - def assign_swept_area_outer_curve_from_2d_vertices(cls, obj: bpy.types.Object, vertices: list[Vector]) -> None: - body = cls.get_body_representation(obj) - model = tool.Ifc.get() - extrusion = tool.Model.get_extrusion(body) - area = extrusion.SweptArea - old_area = area.OuterCurve - - builder = ifcopenshell.util.shape_builder.ShapeBuilder(model) - outer_curve = builder.polyline(vertices, closed=True) - - area.OuterCurve = outer_curve - ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area) - - @classmethod - def get_body_representation(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: - element = tool.Ifc.get_entity(obj) - return ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") - @classmethod def assign_ifcspace_class_to_obj(cls, obj: bpy.types.Object) -> None: - bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace") + bonsai.core.root.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class="IfcSpace", + should_add_representation=False, + ) @classmethod def assign_type_to_obj(cls, obj: bpy.types.Object) -> None: - # TODO this code looks in the wrong spot and suspicious props = tool.Model.get_model_props() ifc_file = tool.Ifc.get() relating_type_id = props.relating_type_id - relating_type = tool.Ifc.get().by_id(int(relating_type_id)) + relating_type = ifc_file.by_id(int(relating_type_id)) ifc_class = relating_type.is_a() instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, ifc_file.schema)[0] - bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class) + bonsai.core.root.assign_class( + tool.Ifc, + tool.Collector, + tool.Root, + obj=obj, + ifc_class=instance_class, + should_add_representation=False, + ) element = tool.Ifc.get_entity(obj) assert element ifcopenshell.api.type.assign_type(ifc_file, related_objects=[element], relating_type=relating_type) + @classmethod + def set_covering_representation_from_polygon( + cls, + obj: bpy.types.Object, + poly: Polygon, + polygon_is_si: bool = True, + ) -> None: + """Create the covering body representation from a polygon, extruded by the type's material layer thickness.""" + element = tool.Ifc.get_entity(obj) + relating_type = ifcopenshell.util.element.get_type(element) + material = ifcopenshell.util.element.get_material(relating_type, should_skip_usage=True) + depth = 0.0 + if material and material.is_a("IfcMaterialLayerSet"): + depth = sum(layer.LayerThickness for layer in material.MaterialLayers) + cls.set_extrusion_representation_from_polygon(obj, element, poly, depth, polygon_is_si) + @classmethod def assign_relating_type_to_element( cls, @@ -1214,15 +1192,6 @@ class Spatial(bonsai.core.tool.Spatial): ) -> None: bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type) - @classmethod - def regen_obj_representation(cls, obj: bpy.types.Object, body: ifcopenshell.entity_instance) -> None: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=body, - ) - @classmethod def set_space_visibility(cls, is_visible: bool) -> None: if tool.Ifc.get().schema == "IFC2X3": diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 8db3ed30fe..83f1751e96 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -203,6 +203,11 @@ class Style(bonsai.core.tool.Style): available_props = props.bl_rna.properties.keys() for prop_blender, prop_ifc in STYLE_PROPS_MAP.items(): + null_prop_name = f"is_{prop_blender}_null" + if null_prop_name in available_props and getattr(props, null_prop_name): + surface_style_data[prop_ifc] = None + continue + class_prop_name = f"{prop_blender}_class" # get detailed color properties if available diff --git a/src/bonsai/bonsai/tool/type.py b/src/bonsai/bonsai/tool/type.py index 84a349f288..882c4b4618 100644 --- a/src/bonsai/bonsai/tool/type.py +++ b/src/bonsai/bonsai/tool/type.py @@ -75,16 +75,7 @@ class Type(bonsai.core.tool.Type): @classmethod def get_model_types(cls) -> list[ifcopenshell.entity_instance]: - ifc_file = tool.Ifc.get() - types = ifc_file.by_type("IfcElementType") - if tool.Ifc.get_schema() == "IFC2X3": - types += ifc_file.by_type("IfcWindowStyle") - types += ifc_file.by_type("IfcDoorStyle") - types += ifc_file.by_type("IfcSpatialStructureElementType") - else: - types += ifc_file.by_type("IfcSpatialElementType") - types += ifc_file.by_type("IfcTypeProduct", include_subtypes=False) - return types + return tool.Ifc.get().by_type("IfcTypeProduct") @classmethod def get_object_data(cls, obj: bpy.types.Object) -> Union[bpy.types.ID, None]: diff --git a/src/bonsai/docs/guides/development/code_style.rst b/src/bonsai/docs/guides/development/code_style.rst index cdc506d5ab..96f3d2b9cc 100644 --- a/src/bonsai/docs/guides/development/code_style.rst +++ b/src/bonsai/docs/guides/development/code_style.rst @@ -7,7 +7,7 @@ Python code formatters For Python code formatting, we use `Black code formatter `__, black settings are stored in the repository's pyproject.toml. -We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository. +We have GitHub workflow `ci-lint` to maintain black formatting across the repository. ``black`` can be installed using ``pip install black`` and files can be formatted with the following example command: diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst index 8852892361..d969791bf6 100644 --- a/src/bonsai/docs/guides/development/index.rst +++ b/src/bonsai/docs/guides/development/index.rst @@ -19,4 +19,5 @@ This chapter covers how you can help contribute to Bonsai. undo_system writing_docs debugging + maintenance ide/index diff --git a/src/bonsai/docs/guides/development/maintenance.rst b/src/bonsai/docs/guides/development/maintenance.rst new file mode 100644 index 0000000000..f08a003fd5 --- /dev/null +++ b/src/bonsai/docs/guides/development/maintenance.rst @@ -0,0 +1,122 @@ +Maintenance +=========== + +This page documents what needs to be updated in various maintenance scenarios. + +Python Version Added or Removed +-------------------------------- + +When adding or removing a supported Python version, update the following: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-lint.yaml`` + - ``MIN_IOS_PY_VERSION`` + * - ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` + - ``pyver`` matrix + * - ``.github/workflows/ci-ifcopenshell-python.yml`` + - ``pyver`` matrix + * - ``nix/build-all.py`` + - ``PYTHON_VERSIONS`` list + * - ``src/bsdd/pyproject.toml`` + - ``requires-python`` + * - ``src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst`` + - add or remove the row in the ZIP packages table + * - ``src/ifcopenshell-python/Makefile`` + - ``SUPPORTED_PYVERSIONS`` + * - ``src/ifcopenshell-python/pyproject.toml`` + - ``requires-python`` + * - ``src/ifcopenshell-python/test/test_package.py`` + - ``SUPPORTED_PY_VERSIONS`` tuple + * - ``win/build-all-win.py`` + - ``PYTHON_VERSIONS`` list + +Blender Version Updated +----------------------- + +When a new Blender version is released and supported: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-bonsai.yml`` + - ``pyver`` matrix + * - ``.github/workflows/ci-bonsai-daily.yml`` + - Blender download URL + +Blender's Bundled Python Version Updated +----------------------------------------- + +When Blender ships with a new Python version: + +.. list-table:: + :header-rows: 1 + + * - File + - What to update + * - ``.github/workflows/ci-lint.yaml`` + - ``MIN_BLENDER_PY_VERSION`` + * - ``.github/scripts/publish-bonsai-releases.py`` + - ``CURRENT_PYTHON_VERSION`` + * - ``src/bonsai/Makefile`` + - ``SUPPORTED_PYVERSIONS`` + * - ``src/bonsai/scripts/dev_environment.py`` + - ``PYTHON_VERSION`` mapping (Blender version, bundled Python version) + +Release +------- + +Notes: + +- Typically all packages are released at once using the same version schema +- The ``README.md`` badges can serve as a visual reference for what versions have been released +- Corrective Release (if needed after a standard release): + + - Create a new branch from the release tag (e.g., from the ``ifcopenshell-0.8.5`` tag) + - Update ``VERSION`` with the ``-post1`` suffix (e.g., ``0.8.5-post1``, **not** ``.post1``) + - The hyphen is required for semantic versioning compliance; Blender will not process ``.post1`` suffixes correctly + - Follow the standard release process for the corrective version + +- Multiple Blender Python Versions: + + - Blender does not allow multiple builds for the same platform with different Python versions (e.g., cannot have both ``bonsai_py311-0.8.5-windows-x64.zip`` and ``bonsai_py313-0.8.5-windows-x64.zip``) + - Workaround: publish different Python versions as different extension versions (e.g., py313 as ``0.8.5`` and py311 as ``0.8.5-post1``) + - Set the maximum Blender version on the Blender extensions platform UI to prevent conflicts (e.g., set max version ``5.1.0`` for ``0.8.5-post1``, which restricts it to versions below 5.1.0) + +Things to update: + +- ``.github/workflows/ci-bcf-pypi.yml`` - release `bcf-client `_ to PyPI +- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases +- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd `_ to PyPI +- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d `_ to PyPI +- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d `_ to PyPI +- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash `_ to PyPI +- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases +- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv `_ to PyPI +- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff `_ to PyPI +- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit `_ to PyPI +- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm `_ to PyPI +- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson `_ to PyPI +- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp `_ to PyPI +- ``.github/workflows/ci-ifcopenshell-python.yml`` - release ifcopenshell-python binaries in GitHub releases +- ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - release `ifcopenshell `_ wheels to PyPI +- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch `_ to PyPI +- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery `_ to PyPI +- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases +- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester `_ to PyPI +- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels `_ +- ``.github/workflows/publish-bonsai-releases.yml`` - publish Bonsai Blender extension to `Blender extensions platform `_ + + - ā— Requires ``BLENDER_EXTENSIONS_TOKEN`` secret to be set - ā— not yet configured + +- Publishing documentation and websites (see `website `_ repository): + + - `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org `_ (`ifcopenshell_org_docs `_ repo) + - `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org `_ (`bonsaibim_org_docs `_ repo) + - `publish-websites.yml` - publishes `bonsaibim.org `_ (`bonsaibim_org_static_html `_ repo) and `ifcopenshell.org `_ (`ifcopenshell_org_static_html `_ repo) +- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly diff --git a/src/bonsai/docs/reference/project_overview/project_info.rst b/src/bonsai/docs/reference/project_overview/project_info.rst index f2fe105f8c..ffe33427a6 100644 --- a/src/bonsai/docs/reference/project_overview/project_info.rst +++ b/src/bonsai/docs/reference/project_overview/project_info.rst @@ -58,7 +58,7 @@ Fields Class** based on the IFC Schema version. **Unit System** - Choose between metric and imperial units of measurement when creating a project. + Choose between metric and imperial units of measurement when creating a project. Project data is stored in this Unit System and displayed according to e.g. Length Unit, Area Unit, Volume Unit. Properly changing the Unit System after project creation requires conversion. See `Blender Manual : Scene Properties : Units `_ for a description of changing the display units e.g. from Feet to Adaptive (enable Separate Units option) for Feet-and-Inches. **Length Unit** Depending on the unit system, choose the default unit to be used for all length measurements. Lengths are used for moving objects around in the 3D scene, as well as lengths, widths, height, and depth quantity take-off data. diff --git a/src/bonsai/scripts/bonsai_deps.py b/src/bonsai/scripts/bonsai_deps.py new file mode 100644 index 0000000000..678fa49bb1 --- /dev/null +++ b/src/bonsai/scripts/bonsai_deps.py @@ -0,0 +1,23 @@ +"""Clone or update Bonsai external dependencies. + +Must be run from the repository root. +""" + +import subprocess +from pathlib import Path + +DEPS = [ + ("https://projects.blender.org/pioverfour/sun_position.git", "sun_position"), + ("https://github.com/kevancress/MeasureIt_ARCH", "MeasureIt_ARCH"), + ("https://github.com/nortikin/sverchok.git", "sverchok"), +] + +base = Path("src/bonsai/external_dependencies") +base.mkdir(parents=True, exist_ok=True) + +for url, name in DEPS: + path = base / name + if not path.exists(): + subprocess.check_call(["git", "clone", url, str(path)]) + else: + subprocess.check_call(["git", "-C", str(path), "pull", "--rebase"]) diff --git a/src/bonsai/scripts/bonsai_translations.py b/src/bonsai/scripts/bonsai_translations.py index fbf11df6e8..7b6ae960db 100644 --- a/src/bonsai/scripts/bonsai_translations.py +++ b/src/bonsai/scripts/bonsai_translations.py @@ -273,10 +273,10 @@ if BPY_IS_LOADED: f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.", ) - from ui_translate.settings import ( # pyright: ignore[reportMissingImports] + from ui_translate.settings import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] settings as ui_translate_settings, ) - from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] + from ui_translate.update_ui import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] UI_OT_i18n_updatetranslation_init_settings, ) diff --git a/src/bonsai/scripts/gbxml.py b/src/bonsai/scripts/gbxml.py index 7bd8063a15..6cf104f2c1 100644 --- a/src/bonsai/scripts/gbxml.py +++ b/src/bonsai/scripts/gbxml.py @@ -23,7 +23,9 @@ import bpy # sys.path.append('C:\Program Files\Python37\Lib\site-packages') import lxml.etree -from bspy import Gbxml # pyright: ignore[reportMissingImports] +from bspy import ( # ty: ignore[unresolved-import] + Gbxml, # pyright: ignore[reportMissingImports] +) class GbxmlExporter: diff --git a/src/bonsai/scripts/generate_steel_profiles_library.py b/src/bonsai/scripts/generate_steel_profiles_library.py index 255a448127..ca45648122 100644 --- a/src/bonsai/scripts/generate_steel_profiles_library.py +++ b/src/bonsai/scripts/generate_steel_profiles_library.py @@ -22,7 +22,7 @@ from math import pi from pathlib import Path -import boltspy as bolts # pyright: ignore[reportMissingImports] +import boltspy as bolts # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.api import ifcopenshell.api.material import ifcopenshell.api.project diff --git a/src/bonsai/scripts/obj2ifc-meshlab.py b/src/bonsai/scripts/obj2ifc-meshlab.py index 70849e11d9..d707c8bda7 100644 --- a/src/bonsai/scripts/obj2ifc-meshlab.py +++ b/src/bonsai/scripts/obj2ifc-meshlab.py @@ -31,7 +31,7 @@ import ifcopenshell.api.spatial import ifcopenshell.api.unit import ifcopenshell.guid import numpy as np -import pymeshlab # pyright: ignore[reportMissingImports] +import pymeshlab # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Obj2Ifc: diff --git a/src/bonsai/scripts/obj2ifc.py b/src/bonsai/scripts/obj2ifc.py index ec5459c4a3..0b3252cd1c 100644 --- a/src/bonsai/scripts/obj2ifc.py +++ b/src/bonsai/scripts/obj2ifc.py @@ -31,7 +31,7 @@ import ifcopenshell.api.spatial import ifcopenshell.api.unit import ifcopenshell.guid import numpy as np -import pywavefront # pyright: ignore[reportMissingImports] +import pywavefront # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Obj2Ifc: diff --git a/src/bonsai/test/bim/feature/covering.feature b/src/bonsai/test/bim/feature/covering.feature index 4eb97649da..cefa554d41 100644 --- a/src/bonsai/test/bim/feature/covering.feature +++ b/src/bonsai/test/bim/feature/covering.feature @@ -2,7 +2,7 @@ Feature: Covering Covers covering tool. -Scenario: Execute generate flooring coverings from walls +Scenario: Add flooring from walls Given an empty IFC project And I load the demo construction library And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" @@ -24,9 +24,9 @@ Scenario: Execute generate flooring coverings from walls And the cursor is at "0,2.0,0" And I set "scene.BIMModelProperties.length" to "1.9" And I press "bim.add_occurrence" - # add_instance_flooring_coverings_from_walls is expecting FLOORING predefined type. + # Set COV30 predefined type to FLOORING. And the object "IfcCoveringType/COV30" is selected - And I look at the "Attributes" panel + And I look at the "Object Attributes" panel And I click "Edit" And I set the "PredefinedType" property to "FLOORING" And I click "Save Attributes" @@ -42,3 +42,108 @@ Scenario: Execute generate flooring coverings from walls Then the object "IfcCovering/Covering0" exists And the object "IfcCovering/Covering0" is at "1.8,1.05,0.0" And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03" + +Scenario: Add ceiling from walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall" is selected + And I press "bim.change_layer_length(length=3.6)" + And the cursor is at "3.6,0.1,3" + And I set "scene.BIMModelProperties.length" to "2.0" + And I press "bim.add_occurrence" + And the cursor is at "3.5,2.1,3" + And I set "scene.BIMModelProperties.length" to "3.5" + And I press "bim.add_occurrence" + And the cursor is at "0,2.0,0" + And I set "scene.BIMModelProperties.length" to "1.9" + And I press "bim.add_occurrence" + # Set COV30 predefined type to CEILING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "CEILING" + And I click "Save Attributes" + # Run the operator with ceiling height = 2.7 (default). + When the object "IfcWall/Wall" is selected + And additionally the object "IfcWall/Wall.001" is selected + And additionally the object "IfcWall/Wall.002" is selected + And additionally the object "IfcWall/Wall.003" is selected + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_ceiling_coverings_from_walls" + Then the object "IfcCovering/Covering0" exists + And the object "IfcCovering/Covering0" is at "1.8,1.05,2.7" + And the object "IfcCovering/Covering0" dimensions are "3.4,1.9,0.03" + +Scenario: Add flooring from cursor + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the cursor is at "1.1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.001" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the cursor is at "0,.9,0" + And I press "bim.add_occurrence" + And the cursor is at "-1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.003" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the object "IfcWall/Wall.003" is moved to "0,0,0" + # Set COV30 predefined type to FLOORING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "FLOORING" + And I click "Save Attributes" + # Generate covering from cursor inside the room. + When the cursor is at "0.5,0.5,0" + And I deselect all objects + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_flooring_covering_from_cursor" + Then the object "IfcCovering/Covering" exists + And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03" + +Scenario: Add ceiling from cursor + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the cursor is at "1.1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.001" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the cursor is at "0,.9,0" + And I press "bim.add_occurrence" + And the cursor is at "-1,0,0" + And I press "bim.add_occurrence" + And the object "IfcWall/Wall.003" is selected + And I press "bim.hotkey(hotkey='S_R')" + And the object "IfcWall/Wall.003" is moved to "0,0,0" + # Set COV30 predefined type to CEILING. + And the object "IfcCoveringType/COV30" is selected + And I look at the "Object Attributes" panel + And I click "Edit" + And I set the "PredefinedType" property to "CEILING" + And I click "Save Attributes" + # Generate covering from cursor inside the room. + When the cursor is at "0.5,0.5,0" + And I deselect all objects + And I set "scene.BIMModelProperties.ifc_class" to "IfcCoveringType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcCoveringType') if e.Name == 'COV30'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_instance_ceiling_covering_from_cursor" + Then the object "IfcCovering/Covering" exists + And the object "IfcCovering/Covering" dimensions are "1,0.8,0.03" diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature index cff150b3b4..0f0c5a3b17 100644 --- a/src/bonsai/test/bim/feature/drawing.feature +++ b/src/bonsai/test/bim/feature/drawing.feature @@ -385,6 +385,51 @@ Scenario: Edit text - change literal When I click "Edit Text" Then I see "Hello World" +Scenario: Add text literal + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I save IFC project + And I look at the "Drawings" panel + And I click "IMPORT" + And I click "ADD" + And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')" + And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list + And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list + And I press "bim.add_annotation" + And the object "IfcAnnotation/TEXT" is selected + And I look at the "BIM_PT_text" panel + And I click "Enable Editing Text" + And I click the "ADD" after the text "Literals:" + And I set the "2nd Literal" property to "New Literal" + When I click "Edit Text" + Then I see "New Literal" + +Scenario: Remove text literal + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I save IFC project + And I look at the "Drawings" panel + And I click "IMPORT" + And I click "ADD" + And I press "bim.toggle_target_view(option="EXPAND", target_view='PLAN_VIEW')" + And I select the "PLAN_VIEW" item in the "BIM_UL_drawinglist" list + And I click "VIEW_CAMERA_UNSELECTED" in the row where I see "PLAN_VIEW" in the "1st" list + And I press "bim.add_annotation" + And the object "IfcAnnotation/TEXT" is selected + And I look at the "BIM_PT_text" panel + And I click "Enable Editing Text" + And I set the "Literal" property to "Keep This" + And I click the "ADD" after the text "Literals:" + And I set the "2nd Literal" property to "Remove This" + And I click "Edit Text" + And I click "Enable Editing Text" + When I click the "2nd" "X" + And I click "Edit Text" + Then I see "Keep This" + And I don't see "Remove This" + Scenario: Add reference image Given an empty IFC project And I save IFC project diff --git a/src/bonsai/test/bim/feature/material.feature b/src/bonsai/test/bim/feature/material.feature index 2f34cbb893..67525cd8f3 100644 --- a/src/bonsai/test/bim/feature/material.feature +++ b/src/bonsai/test/bim/feature/material.feature @@ -422,6 +422,24 @@ Scenario: Enable editing material set item When I press "bim.enable_editing_material_set_item(material_set_item={material_profile})" Then nothing happens +Scenario: Edit layer item defaults null IsVentilated to FALSE in UI + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I look at the "Class" panel + And I set the "Products" property to "IfcElement" + And I set the "Class" property to "IfcWall" + And I click "Assign IFC Class" + And I press "bim.add_material()" + And I set "active_object.BIMObjectMaterialProperties.material_type" to "IfcMaterialLayerSet" + And I press "bim.assign_material" + And I press "bim.enable_editing_assigned_material" + And the variable "layer" is "{ifc}.by_type('IfcMaterialLayer')[0].id()" + And I press "bim.enable_editing_material_set_item(material_set_item={layer})" + When I evaluate expression "attrs = bpy.context.active_object.BIMObjectMaterialProperties.material_set_item_attributes; is_vent = next(a for a in attrs if a.name == 'IsVentilated'); assert is_vent.enum_value == 'FALSE'; assert is_vent.is_null is True" + And I press "bim.edit_material_set_item(material_set_item={layer})" + Then I evaluate expression "assert {ifc}.by_id({layer}).IsVentilated is None" + Scenario: Add material set layer Given an empty IFC project And I add a cube diff --git a/src/bonsai/test/bim/feature/model.feature b/src/bonsai/test/bim/feature/model.feature index ad5b25bd07..064620a574 100644 --- a/src/bonsai/test/bim/feature/model.feature +++ b/src/bonsai/test/bim/feature/model.feature @@ -396,6 +396,44 @@ Scenario: Add a slab And the object "IfcSlab/Slab" bottom left corner is at "0,0,0" And the object "IfcSlab/Slab" top right corner is at "1,1,0.2" +Scenario: Extend walls to underside + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And I set "scene.BIMModelProperties.ifc_class" to "IfcSlabType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcSlabType') if e.Name == 'FLR200'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + And the object "IfcSlab/Slab" is moved to "0,0,2.5" + When the object "IfcWall/Wall" is selected + And additionally the object "IfcSlab/Slab" is selected + And I look at the tool header + And I click "Extend To Underside" + Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5" + +Scenario: Extend walls to underside - extending to a tessellated gable roof + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_occurrence" + # Create gable roof: a cube turned into a prism with a ridge. + And I add a cube of size "1" at "0.5,0.05,3" + And the object "Cube" is selected + And I evaluate expression "obj = bpy.context.active_object; [setattr(v.co, 'y', 0) for v in obj.data.vertices if v.co.z > 0]" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_class" to "IfcRoof" + And I press "bim.assign_class" + When the object "IfcWall/Wall" is selected + And additionally the object "IfcRoof/Cube" is selected + And I look at the tool header + And I click "Extend To Underside" + Then the object "IfcWall/Wall" dimensions are "1,0.1,2.5" + Scenario: Enable editing a slab profile Given an empty IFC project And I load the demo construction library diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index fe6fbcba15..79e8494441 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -133,7 +133,11 @@ class PanelSpy: self.spied_labels.append(kwargs["text"]) return self elif self.spied_attr == "prop": - props, name = args + if args: + props, name = args + else: + props = kwargs.get("data") + name = kwargs.get("property") props: bpy.types.bpy_struct text = kwargs.get("text", props.bl_rna.properties[name].name) icon = kwargs.get("icon", None) @@ -242,7 +246,7 @@ class TemplateListItemSpy(PanelSpy): self.spied_props: list[dict[str, Any]] = [] self.spied_operators: list[dict[str, Any]] = [] if len(signature(blender_panel.draw_item).parameters) == 8: - blender_panel.draw_item( + blender_panel.draw_item( # ty:ignore[missing-argument] self, bpy.context, self, @@ -390,6 +394,32 @@ def i_look_at_the_panel_panel(panel: str) -> None: panel_spy.refresh_spy() +@given(parsers.parse("I look at the tool header")) +@when(parsers.parse("I look at the tool header")) +@then(parsers.parse("I look at the tool header")) +def i_look_at_the_tool_header() -> None: + from bonsai.bim.module.model.workspace import EditObjectUI + + class MockRegion: + type = "UI" + + class MockContext: + def __getattr__(self, name): + if name == "region": + return MockRegion() + return getattr(bpy.context, name) + + global panel_spy + panel_spy = PanelSpy(EditObjectUI) + panel_spy.is_spy_dirty = False + panel_spy.spied_attr = None + panel_spy.spied_labels = [] + panel_spy.spied_props = [] + panel_spy.spied_operators = [] + panel_spy.spied_lists = [] + EditObjectUI.draw(MockContext(), panel_spy) + + @given(parsers.parse('I open the "{name}" menu')) @when(parsers.parse('I open the "{name}" menu')) @then(parsers.parse('I open the "{name}" menu')) @@ -610,8 +640,9 @@ def i_see_the_prop_property_is_value(prop, value): @then(parsers.parse('I set the "{prop}" property to "{value}"')) def i_set_the_prop_property_to_value(prop: str, value: str): """ - :param prop: Could be either property name, property text, property icon - or property index (e.g. "1st", "2nd", "5th"). + :param prop: Could be either property name, property text, property icon, + property index (e.g. "1st", "2nd", "5th"), or Nth named property + (e.g. "2nd Literal" for the 2nd property called "Literal"). :param value: For boolean propeties - 'TRUE' or 'FALSE'. """ @@ -619,12 +650,28 @@ def i_set_the_prop_property_to_value(prop: str, value: str): assert panel_spy panel_spy.refresh_spy() is_nth = False - if prop[0].isnumeric() and prop.endswith(("st", "nd", "th")): + is_nth_named = False + nth_target = 0 + prop_name = prop + if " " in prop and prop[0].isnumeric(): + parts = prop.split(" ", 1) + if parts[0].endswith(("st", "nd", "th")): + is_nth_named = True + nth_target = int(parts[0][:-2]) - 1 + prop_name = parts[1] + elif prop[0].isnumeric() and prop.endswith(("st", "nd", "th")): is_nth = True + named_count = 0 for nth, spied_prop in enumerate(panel_spy.spied_props): if is_nth and nth != int(prop[:-2]) - 1: continue - if not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + if is_nth_named: + if prop_name not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): + continue + if named_count != nth_target: + named_count += 1 + continue + elif not is_nth and prop not in (spied_prop["name"], spied_prop["text"], spied_prop["icon"]): continue if spied_prop["prop_type"] == "BOOLEAN": if value == "TRUE": @@ -873,6 +920,29 @@ def i_click_button(button): _i_click_button_on_panel(button, panel_spy) +@given(parsers.parse('I click the "{nth}" "{button}"')) +@when(parsers.parse('I click the "{nth}" "{button}"')) +@then(parsers.parse('I click the "{nth}" "{button}"')) +def i_click_the_nth_button(nth, button): + """ + :param nth: Ordinal like "1st", "2nd", "3rd" to select the Nth matching button. + :param button: The text or icon of the button to click. + """ + assert panel_spy + panel_spy.refresh_spy() + target = int(nth[:-2]) - 1 + count = 0 + for spied_operator in panel_spy.spied_operators: + if spied_operator["text"] == button or spied_operator["icon"] == button: + if count == target: + spied_operator["operator"]("INVOKE_DEFAULT", **spied_operator["kwargs"]) + panel_spy.is_spy_dirty = True + return + count += 1 + debug = "\n".join([f"{i} {v}" for i, v in enumerate(panel_spy.spied_operators)]) + assert False, f"Could not find {nth} {button}:\n{debug}" + + @given(parsers.parse('I click the "{button}" after the text "{text}"')) @when(parsers.parse('I click the "{button}" after the text "{text}"')) @then(parsers.parse('I click the "{button}" after the text "{text}"')) diff --git a/src/bonsai/test/core/bootstrap.py b/src/bonsai/test/core/bootstrap.py index 057ee9ffab..cd8371e1c3 100644 --- a/src/bonsai/test/core/bootstrap.py +++ b/src/bonsai/test/core/bootstrap.py @@ -102,6 +102,13 @@ def geometry(): prophet.verify() +@pytest.fixture +def ifcgit(): + prophet = Prophecy(bonsai.core.tool.IfcGit) + yield prophet + prophet.verify() + + @pytest.fixture def georeference(): prophet = Prophecy(bonsai.core.tool.Georeference) diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index a83f026ad3..52822aa754 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -35,9 +35,14 @@ class TestDisableEditingText: class TestEditText: def test_run(self, drawing): - drawing.synchronise_ifc_and_text_attributes("obj").should_be_called() - drawing.update_text_size_pset("obj").should_be_called() - drawing.update_text_annotation_properties("obj").should_be_called() + drawing.export_text_literal_attributes("obj").should_be_called().will_return("literal_attributes") + drawing.export_font_size("obj").should_be_called().will_return("font_size") + drawing.edit_text_font_size("obj", "font_size").should_be_called() + drawing.export_wrap_length("obj").should_be_called().will_return("wrap_length") + drawing.edit_text_wrap_length("obj", "wrap_length").should_be_called() + drawing.export_symbol("obj").should_be_called().will_return("symbol") + drawing.edit_text_symbol("obj", "symbol").should_be_called() + drawing.edit_text_literals("obj", "literal_attributes").should_be_called() drawing.disable_editing_text("obj").should_be_called() subject.edit_text(drawing, obj="obj") @@ -466,6 +471,7 @@ class TestRemoveDrawing: class TestUpdateDrawingName: def test_do_not_update_if_name_unchanged(self, ifc, drawing): drawing.get_name("drawing").should_be_called().will_return("name") + drawing.set_camera_name("drawing", "name").should_be_called() drawing.get_drawing_group("drawing").should_be_called().will_return("group") drawing.get_name("group").should_be_called().will_return("name") drawing.get_drawing_collection("drawing").should_be_called().will_return("collection") @@ -482,6 +488,7 @@ class TestUpdateDrawingName: def test_run(self, ifc, drawing): drawing.get_name("drawing").should_be_called().will_return("oldname") ifc.run("attribute.edit_attributes", product="drawing", attributes={"Name": "name"}).should_be_called() + drawing.set_camera_name("drawing", "name").should_be_called() drawing.get_drawing_group("drawing").should_be_called().will_return("group") drawing.get_name("group").should_be_called().will_return("oldname") ifc.run("attribute.edit_attributes", product="group", attributes={"Name": "name"}).should_be_called() diff --git a/src/bonsai/test/core/test_georeference.py b/src/bonsai/test/core/test_georeference.py index b9b330d816..e95236ec3f 100644 --- a/src/bonsai/test/core/test_georeference.py +++ b/src/bonsai/test/core/test_georeference.py @@ -23,6 +23,7 @@ from test.core.bootstrap import georeference, ifc class TestAddGeoreferencing: def test_run(self, georeference): georeference.add_georeferencing().should_be_called() + georeference.set_model_origin().should_be_called() subject.add_georeferencing(georeference) @@ -35,9 +36,10 @@ class TestEnableEditingGeoreferencing: class TestRemoveGeoreferencing: - def test_run(self, ifc): + def test_run(self, ifc, georeference): ifc.run("georeference.remove_georeferencing").should_be_called() - subject.remove_georeferencing(ifc) + georeference.set_model_origin().should_be_called() + subject.remove_georeferencing(ifc, georeference) class TestDisableEditingGeoreferencing: diff --git a/src/bonsai/test/core/test_ifcgit.py b/src/bonsai/test/core/test_ifcgit.py new file mode 100644 index 0000000000..4884497c8b --- /dev/null +++ b/src/bonsai/test/core/test_ifcgit.py @@ -0,0 +1,329 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import pytest + +import bonsai.core.ifcgit as subject +from test.core.bootstrap import ifc, ifcgit + + +class MockOperator: + def __init__(self): + self.reports = [] + + def report(self, level, message): + self.reports.append((level, message)) + + +class TestCreateRepo: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.get_path_dir("path/to/model.ifc").should_be_called().will_return("path/to") + ifcgit.init_repo("path/to").should_be_called() + subject.create_repo(ifcgit, ifc) + + +class TestAddFile: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.repo_from_path("path/to/model.ifc").should_be_called().will_return("repo") + ifcgit.add_file_to_repo("repo", "path/to/model.ifc").should_be_called() + subject.add_file(ifcgit, ifc) + + +class TestCloneRepo: + def test_successful_clone(self, ifcgit): + ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return("repo") + ifcgit.load_anyifc("repo").should_be_called() + op = MockOperator() + subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op) + assert op.reports == [({"INFO"}, "Repository cloned")] + + def test_failed_clone_reports_error(self, ifcgit): + ifcgit.clone_repo("http://example.com/repo.git", "/local/folder").should_be_called().will_return(None) + op = MockOperator() + subject.clone_repo(ifcgit, "http://example.com/repo.git", "/local/folder", operator=op) + assert op.reports == [({"ERROR"}, "Clone failed")] + + +class TestDiscardUncommitted: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.git_checkout("path/to/model.ifc").should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + subject.discard_uncommitted(ifcgit, ifc) + + +class TestCommitChanges: + def test_commit_on_branch_without_new_branch(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(False) + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "") + + def test_commit_on_branch_with_new_branch(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(False) + ifcgit.checkout_new_branch("path/to/model.ifc", "feature").should_be_called() + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "feature") + + def test_commit_on_detached_head(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.is_head_detached().should_be_called().will_return(True) + ifcgit.git_commit("path/to/model.ifc", "my message").should_be_called() + ifcgit.create_new_branch("feature").should_be_called() + subject.commit_changes(ifcgit, ifc, "my message", "feature") + + +class TestAddTag: + def test_run(self, ifcgit): + ifcgit.add_tag("repo", "abc123", "v1.0", "Release notes").should_be_called() + subject.add_tag(ifcgit, "repo", "abc123", "v1.0", "Release notes") + + +class TestDeleteTag: + def test_run(self, ifcgit): + ifcgit.delete_tag("repo", "v1.0").should_be_called() + subject.delete_tag(ifcgit, "repo", "v1.0") + + +class TestAddRemote: + def test_run(self, ifcgit): + ifcgit.add_remote("repo", "origin", "http://example.com").should_be_called() + subject.add_remote(ifcgit, "repo", "origin", "http://example.com") + + +class TestDeleteRemote: + def test_run(self, ifcgit): + ifcgit.delete_remote("repo", "origin").should_be_called() + subject.delete_remote(ifcgit, "repo", "origin") + + +class TestPush: + def test_push_succeeds_silently(self, ifcgit): + ifcgit.get_active_branch_name().should_be_called().will_return("main") + ifcgit.push("repo", "origin", "main").should_be_called().will_return(None) + subject.push(ifcgit, "repo", "origin", operator=None) + + def test_push_failure_reports_error(self, ifcgit): + ifcgit.get_active_branch_name().should_be_called().will_return("main") + ifcgit.push("repo", "origin", "main").should_be_called().will_return("stderr: rejected") + op = MockOperator() + subject.push(ifcgit, "repo", "origin", operator=op) + assert op.reports == [({"ERROR"}, "stderr: rejected")] + + +class TestRefreshRevisionList: + def test_refreshes_when_repo_has_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.repo_has_commits().should_be_called().will_return(True) + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + subject.refresh_revision_list(ifcgit, ifc) + + def test_skips_when_repo_has_no_heads(self, ifcgit, ifc): + ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.repo_has_commits().should_be_called().will_return(False) + subject.refresh_revision_list(ifcgit, ifc) + # nothing else should be called — Prophecy will verify + + +class TestColouriseRevision: + def test_skips_when_no_step_ids(self, ifcgit): + ifcgit.get_revisions_step_ids().should_be_called().will_return(None) + subject.colourise_revision(ifcgit) + + def test_colourises_with_step_ids(self, ifcgit): + ifcgit.get_revisions_step_ids().should_be_called().will_return("step_ids") + ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids") + ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids") + ifcgit.colourise("final_step_ids").should_be_called() + subject.colourise_revision(ifcgit) + + +class TestColouriseUncommitted: + def test_skips_when_no_step_ids(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return(None) + subject.colourise_uncommitted(ifcgit, ifc, "repo") + + def test_colourises_with_step_ids(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.ifc_diff_ids("repo", None, "HEAD", "path/to/model.ifc").should_be_called().will_return("step_ids") + ifcgit.get_modified_step_ids("step_ids").should_be_called().will_return("modified_step_ids") + ifcgit.update_step_ids("step_ids", "modified_step_ids").should_be_called().will_return("final_step_ids") + ifcgit.colourise("final_step_ids").should_be_called() + subject.colourise_uncommitted(ifcgit, ifc, "repo") + + +class TestSwitchRevision: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.switch_to_revision_item().should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + ifcgit.decolourise().should_be_called() + subject.switch_revision(ifcgit, ifc) + + +class TestMergeBranch: + def test_no_branch_at_selected_commit(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return(None) + subject.merge_branch(ifcgit, ifc, operator=None) + + def test_clean_merge(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge("feature").should_be_called().will_return(None) + ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + ifcgit.decolourise().should_be_called() + subject.merge_branch(ifcgit, ifc, operator=None) + + def test_conflict_mergetool_success(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.commit_merge("path/to/model.ifc").should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + ifcgit.set_display_branch().should_be_called() + ifcgit.git_checkout("path/to/model.ifc").should_be_called() + ifcgit.load_project("path/to/model.ifc").should_be_called() + ifcgit.refresh_revision_list("path/to/model.ifc").should_be_called() + ifcgit.decolourise().should_be_called() + subject.merge_branch(ifcgit, ifc, operator=None) + + def test_conflict_mergetool_failure(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() + op = MockOperator() + subject.merge_branch(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge failed — see the conflict report in the panel below")] + + def test_unknown_merge_error(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge("feature").should_be_called().will_return("error") + op = MockOperator() + subject.merge_branch(ifcgit, ifc, op) + assert op.reports == [({"ERROR"}, "Unknown IFC Merge failure")] + + +class TestDryRunMerge: + def test_no_branch_at_selected_commit(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return(None) + subject.dry_run_merge(ifcgit, ifc, operator=None) + + def test_clean_merge_preview(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + def test_conflict_preview_shows_report(self, ifcgit, ifc): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(conflicts) + ifcgit.git_merge_abort().should_be_called() + ifcgit.store_merge_conflicts(conflicts).should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"WARNING"}, "Merge preview: conflicts found — see the panel below")] + + def test_conflict_preview_mergetool_succeeds(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.config_ifcmerge().should_be_called() + ifcgit.get_selected_branch().should_be_called().will_return("feature") + ifcgit.get_merge_tool("feature").should_be_called().will_return("ifcmerge-forward") + ifcgit.git_merge_no_commit("feature").should_be_called().will_return("conflict") + ifcgit.git_mergetool("ifcmerge-forward", "path/to/model.ifc").should_be_called().will_return(None) + ifcgit.git_merge_abort().should_be_called() + ifcgit.clear_merge_conflicts().should_be_called() + op = MockOperator() + subject.dry_run_merge(ifcgit, ifc, op) + assert op.reports == [({"INFO"}, "Merge preview: no conflicts")] + + +class TestEntityLog: + def test_run(self, ifcgit, ifc): + ifc.get_path().should_be_called().will_return("path/to/model.ifc") + ifcgit.entity_log("path/to/model.ifc", 42).should_be_called().will_return("log text") + op = MockOperator() + subject.entity_log(ifcgit, ifc, 42, op) + assert op.reports == [({"ERROR"}, "log text")] + + +class TestInstallGit: + def test_windows(self, ifcgit): + import unittest.mock as mock + + with mock.patch("platform.system", return_value="Windows"): + ifcgit.install_git_windows(operator="op").should_be_called() + subject.install_git(ifcgit, "op") + + def test_non_windows_does_nothing(self, ifcgit): + import unittest.mock as mock + + with mock.patch("platform.system", return_value="Linux"): + subject.install_git(ifcgit, "op") + # no tool method should be called — Prophecy will verify + + +class TestFetch: + def test_run(self, ifcgit): + ifcgit.fetch("origin").should_be_called() + subject.fetch(ifcgit, "origin") + + +class TestRunGitDiff: + def test_run(self, ifcgit): + ifcgit.run_git_diff("operator", False).should_be_called() + subject.run_git_diff(ifcgit, "operator", False) diff --git a/src/bonsai/test/core/test_type.py b/src/bonsai/test/core/test_type.py index e46cf8168f..031e414f95 100644 --- a/src/bonsai/test/core/test_type.py +++ b/src/bonsai/test/core/test_type.py @@ -22,8 +22,9 @@ from test.core.bootstrap import geometry, ifc, model, type class TestAssignType: def test_assigning_and_switching_to_an_existing_type_data(self, ifc, model, type): + type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - type.has_material_usage("element").should_be_called().will_return(False) + model.get_usage_type("type").should_be_called(2).will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return("type_obj_data") type.change_object_data("obj", "type_obj_data", is_global=False).should_be_called() @@ -31,9 +32,10 @@ class TestAssignType: type.disable_editing("obj").should_be_called() subject.assign_type(ifc, model, type, element="element", type="type") - def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, type): + def test_assigning_and_not_changing_data_if_the_type_has_no_data(self, ifc, model, type): + type.record_material_usage_attributes("element").should_be_called().will_return(None) ifc.run("type.assign_type", related_objects=["element"], relating_type="type").should_be_called() - type.has_material_usage("element").should_be_called().will_return(False) + model.get_usage_type("type").should_be_called(2).will_return(None) ifc.get_object("type").should_be_called().will_return("type_obj") type.get_object_data("type_obj").should_be_called().will_return(None) ifc.get_object("element").should_be_called().will_return("obj") diff --git a/src/bonsai/test/tool/test_aggregate.py b/src/bonsai/test/tool/test_aggregate.py index bab71b2c4a..4fd9388c13 100644 --- a/src/bonsai/test/tool/test_aggregate.py +++ b/src/bonsai/test/tool/test_aggregate.py @@ -18,6 +18,7 @@ import bpy import ifcopenshell +import ifcopenshell.api.aggregate import ifcopenshell.api.context import ifcopenshell.api.geometry import ifcopenshell.api.root @@ -99,6 +100,42 @@ class TestCanAggregate(NewFile): subelement_obj = bpy.data.objects.new("Object", None) assert subject.can_aggregate(element_obj, subelement_obj) is False + def test_element_cannot_aggregate_to_itself(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifc.createIfcElementAssembly() + element_obj = bpy.data.objects.new("Object", None) + tool.Ifc.link(element, element_obj) + assert subject.can_aggregate(element_obj, element_obj) is False + + def test_cyclic_aggregation_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assembly_a = ifc.createIfcElementAssembly() + assembly_a_obj = bpy.data.objects.new("AssemblyA", None) + tool.Ifc.link(assembly_a, assembly_a_obj) + beam = ifc.createIfcBeam() + beam_obj = bpy.data.objects.new("Beam", None) + tool.Ifc.link(beam, beam_obj) + ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_a) + assert subject.can_aggregate(beam_obj, assembly_a_obj) is False + + def test_deep_cyclic_aggregation_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + assembly_a = ifc.createIfcElementAssembly() + assembly_a_obj = bpy.data.objects.new("AssemblyA", None) + tool.Ifc.link(assembly_a, assembly_a_obj) + assembly_b = ifc.createIfcElementAssembly() + assembly_b_obj = bpy.data.objects.new("AssemblyB", None) + tool.Ifc.link(assembly_b, assembly_b_obj) + beam = ifc.createIfcBeam() + beam_obj = bpy.data.objects.new("Beam", None) + tool.Ifc.link(beam, beam_obj) + ifcopenshell.api.aggregate.assign_object(ifc, products=[assembly_b], relating_object=assembly_a) + ifcopenshell.api.aggregate.assign_object(ifc, products=[beam], relating_object=assembly_b) + assert subject.can_aggregate(beam_obj, assembly_a_obj) is False + class TestHasPhysicalBodyRepresentation(NewFile): def test_run(self): diff --git a/src/bonsai/test/tool/test_classification.py b/src/bonsai/test/tool/test_classification.py index 7bfe2a44d7..a48df50db4 100644 --- a/src/bonsai/test/tool/test_classification.py +++ b/src/bonsai/test/tool/test_classification.py @@ -42,6 +42,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element @@ -66,6 +67,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element @@ -110,6 +112,7 @@ class TestAddClassificationReferenceFromBSDD(NewFile): bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] bpy.ops.bim.assign_class(ifc_class="IfcSpace", predefined_type="SPACE", userdefined_type="") + tool.Blender.set_active_object(obj) element = tool.Ifc.get_entity(obj) assert element diff --git a/src/bonsai/test/tool/test_cost.py b/src/bonsai/test/tool/test_cost.py new file mode 100644 index 0000000000..3cfbe03c91 --- /dev/null +++ b/src/bonsai/test/tool/test_cost.py @@ -0,0 +1,49 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + + +import test.bim.bootstrap +import ifcopenshell.api.cost + +import bonsai.core.tool +import bonsai.tool as tool +import test.bim.bootstrap +from test.bim.bootstrap import NewFile + +from bonsai.tool.cost import Cost as subject + +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(subject(), bonsai.core.tool.Cost) + +class TestDisableEditingCostItemParent(NewFile): + def test_avoid_recursion_error(newfile, monkeypatch): + class DummyProps: + def __init__(self): + self.change_cost_item_parent = None + self.active_cost_item_id = 5 + + props = DummyProps() + monkeypatch.setattr( + "bonsai.tool.Cost.get_cost_props", + lambda: props + ) + subject.disable_editing_cost_item_parent() + assert props.active_cost_item_id == 0 + assert props.change_cost_item_parent is not False + diff --git a/src/bonsai/test/tool/test_ifcgit.py b/src/bonsai/test/tool/test_ifcgit.py new file mode 100644 index 0000000000..964bd96fd2 --- /dev/null +++ b/src/bonsai/test/tool/test_ifcgit.py @@ -0,0 +1,581 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2025 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import os +import tempfile + +import bonsai.core.tool +from bonsai.tool.ifcgit import IfcGit, IfcGitRepo +from test.bim.bootstrap import NewFile + +try: + import git + import git.exc + + HAS_GIT = True +except ImportError: + HAS_GIT = False + +import pytest + +requires_git = pytest.mark.skipif(not HAS_GIT, reason="GitPython not available") + + +def _make_repo(tmpdir: str) -> "git.Repo": + """Initialise a git repo with user config (needed for commits).""" + repo = git.Repo.init(tmpdir) + with repo.config_writer() as cfg: + cfg.set_value("user", "name", "Test User") + cfg.set_value("user", "email", "test@example.com") + return repo + + +def _commit_ifc(repo: "git.Repo", tmpdir: str, content: str, message: str) -> str: + """Write content to model.ifc, stage and commit it. Returns commit hexsha.""" + ifc_path = os.path.join(tmpdir, "model.ifc") + with open(ifc_path, "w") as f: + f.write(content) + repo.index.add([os.path.normpath(ifc_path)]) + commit = repo.index.commit(message) + return commit.hexsha + + +# --------------------------------------------------------------------------- +# Interface conformance +# --------------------------------------------------------------------------- + + +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(IfcGit(), bonsai.core.tool.IfcGit) + + +# --------------------------------------------------------------------------- +# Pure-logic (no git required) +# --------------------------------------------------------------------------- + + +class TestIsValidRefFormat(NewFile): + def test_simple_name(self): + assert IfcGit.is_valid_ref_format("main") + + def test_name_with_slash(self): + assert IfcGit.is_valid_ref_format("feature/my-feature") + + def test_name_with_numbers(self): + assert IfcGit.is_valid_ref_format("release-2024") + + def test_rejects_leading_dot(self): + assert not IfcGit.is_valid_ref_format(".hidden") + + def test_rejects_leading_dash(self): + assert not IfcGit.is_valid_ref_format("-branch") + + def test_rejects_space(self): + assert not IfcGit.is_valid_ref_format("my branch") + + def test_rejects_double_dot(self): + assert not IfcGit.is_valid_ref_format("my..branch") + + def test_rejects_tilde(self): + assert not IfcGit.is_valid_ref_format("my~branch") + + def test_rejects_caret(self): + assert not IfcGit.is_valid_ref_format("my^branch") + + def test_rejects_trailing_dot(self): + assert not IfcGit.is_valid_ref_format("branch.") + + def test_rejects_trailing_slash(self): + assert not IfcGit.is_valid_ref_format("branch/") + + def test_rejects_dot_lock_suffix(self): + assert not IfcGit.is_valid_ref_format("branch.lock") + + def test_rejects_empty_string(self): + assert not IfcGit.is_valid_ref_format("") + + def test_rejects_at_brace(self): + assert not IfcGit.is_valid_ref_format("branch@{upstream}") + + +class TestGetPathDir(NewFile): + def test_returns_parent_directory(self): + assert IfcGit.get_path_dir("/some/path/model.ifc") == "/some/path" + + def test_handles_nested_path(self): + assert IfcGit.get_path_dir("/a/b/c/d.ifc") == "/a/b/c" + + def test_returns_absolute_path(self): + result = IfcGit.get_path_dir("/a/b/model.ifc") + assert os.path.isabs(result) + + +# --------------------------------------------------------------------------- +# File I/O +# --------------------------------------------------------------------------- + + +class TestDos2Unix(NewFile): + def test_converts_crlf_to_lf(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"line1\r\nline2\r\nline3\r\n") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"line1\nline2\nline3\n" + finally: + os.unlink(path) + + def test_lf_only_file_is_unchanged(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"line1\nline2\nline3\n") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"line1\nline2\nline3\n" + finally: + os.unlink(path) + + def test_empty_file_unchanged(self): + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False, mode="wb") as f: + f.write(b"") + path = f.name + try: + IfcGit.dos2unix(path) + with open(path, "rb") as f: + assert f.read() == b"" + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# Git repo initialisation +# --------------------------------------------------------------------------- + + +class TestInitRepo(NewFile): + @requires_git + def test_creates_git_repo(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + IfcGit.init_repo(tmpdir) + assert IfcGitRepo.repo is not None + assert os.path.isdir(IfcGitRepo.repo.git_dir) + IfcGitRepo.repo = None + + @requires_git + def test_creates_info_attributes_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + IfcGit.init_repo(tmpdir) + attrs_path = os.path.join(IfcGitRepo.repo.git_dir, "info", "attributes") + assert os.path.isfile(attrs_path) + with open(attrs_path) as f: + assert "*.ifc text" in f.read() + IfcGitRepo.repo = None + + +class TestRepoFromPath(NewFile): + @requires_git + def test_finds_repo_from_file_in_root(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + repo = _make_repo(tmpdir) + ifc_path = os.path.join(tmpdir, "model.ifc") + open(ifc_path, "w").close() + result = IfcGit.repo_from_path(ifc_path) + assert result is not None + assert os.path.abspath(result.working_dir) == os.path.abspath(tmpdir) + IfcGitRepo.repo = None + + @requires_git + def test_finds_repo_from_subdirectory(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + _make_repo(tmpdir) + subdir = os.path.join(tmpdir, "sub", "dir") + os.makedirs(subdir) + result = IfcGit.repo_from_path(subdir) + assert result is not None + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_for_path_outside_any_repo(self): + with tempfile.TemporaryDirectory() as tmpdir: + IfcGitRepo.repo = None + # a plain directory with no .git anywhere above it (using /tmp directly + # is safe since /tmp is not a git repo on this system) + result = IfcGit.repo_from_path("/nonexistent/path/that/does/not/exist") + assert result is None + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# Git repo configuration +# --------------------------------------------------------------------------- + + +class TestConfigInfoAttributes(NewFile): + @requires_git + def test_creates_attributes_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGit.config_info_attributes(repo) + attrs_path = os.path.join(repo.git_dir, "info", "attributes") + assert os.path.isfile(attrs_path) + with open(attrs_path) as f: + assert "*.ifc text" in f.read() + + @requires_git + def test_does_not_overwrite_existing_attributes(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + attrs_path = os.path.join(repo.git_dir, "info", "attributes") + os.makedirs(os.path.dirname(attrs_path), exist_ok=True) + with open(attrs_path, "w") as f: + f.write("*.png binary\n") + IfcGit.config_info_attributes(repo) + with open(attrs_path) as f: + content = f.read() + assert "*.png binary" in content # original content preserved + + +class TestConfigPush(NewFile): + @requires_git + def test_sets_push_defaults(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGit.config_push(repo) + reader = repo.config_reader() + assert reader.get_value("push", "default") == "current" + assert reader.get_value("push", "autoSetupRemote") is True + + @requires_git + def test_does_not_overwrite_existing_push_section(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + with repo.config_writer() as w: + w.set_value("push", "default", "simple") + IfcGit.config_push(repo) + reader = repo.config_reader() + assert reader.get_value("push", "default") == "simple" + + +# --------------------------------------------------------------------------- +# Branch / tag lookups +# --------------------------------------------------------------------------- + + +class TestBranchesByHexsha(NewFile): + @requires_git + def test_maps_head_commit_to_active_branch(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + result = IfcGit.branches_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + names = [b.name for b in result[head_sha]] + assert repo.active_branch.name in names + + @requires_git + def test_returns_empty_dict_when_no_commits(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + result = IfcGit.branches_by_hexsha(repo) + assert result == {} + + @requires_git + def test_includes_both_branches_when_pointing_to_same_commit(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_head("feature") + result = IfcGit.branches_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + names = [b.name for b in result[head_sha]] + assert len(names) == 2 + + +class TestTagsByHexsha(NewFile): + @requires_git + def test_returns_empty_dict_when_no_tags(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + assert IfcGit.tags_by_hexsha(repo) == {} + + @requires_git + def test_maps_commit_to_annotated_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v1.0", message="Release 1.0") + result = IfcGit.tags_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + assert result[head_sha][0].name == "v1.0" + + @requires_git + def test_maps_commit_to_lightweight_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v0.1") + result = IfcGit.tags_by_hexsha(repo) + head_sha = repo.head.commit.hexsha + assert head_sha in result + + +class TestDeleteTag(NewFile): + @requires_git + def test_removes_existing_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + repo.create_tag("v1.0") + IfcGit.delete_tag(repo, "v1.0") + assert "v1.0" not in [t.name for t in repo.tags] + + @requires_git + def test_does_not_raise_for_nonexistent_tag(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + IfcGit.delete_tag(repo, "does-not-exist") # must not raise + + +# --------------------------------------------------------------------------- +# IFC diff parsing +# --------------------------------------------------------------------------- + + +class TestIfcDiffIds(NewFile): + @requires_git + def test_detects_modified_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n", "update") + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["modified"] + assert result["added"] == set() + assert result["removed"] == set() + + @requires_git + def test_detects_added_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('new',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "add site", + ) + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 2 in result["added"] + assert 1 not in result["modified"] + assert result["removed"] == set() + + @requires_git + def test_detects_removed_entity(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc(repo, tmpdir, "", "clear") + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["removed"] + assert result["added"] == set() + assert result["modified"] == set() + + @requires_git + def test_no_changes_between_identical_commits(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha = _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + result = IfcGit.ifc_diff_ids(repo, sha, sha, ifc_path) + assert result["modified"] == set() + assert result["added"] == set() + assert result["removed"] == set() + + @requires_git + def test_diff_against_working_tree_with_none_hash_a(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + _commit_ifc(repo, tmpdir, "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n", "init") + ifc_path = os.path.join(tmpdir, "model.ifc") + # Make an uncommitted change in working tree + with open(ifc_path, "w") as f: + f.write("#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n") + result = IfcGit.ifc_diff_ids(repo, None, "HEAD", ifc_path) + assert 1 in result["modified"] + + @requires_git + def test_handles_multiple_changed_entities(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + sha_a = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('abc',$,$,$,$,$,$,$,$);\n#2=IFCSITE('s',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "init", + ) + ifc_path = os.path.join(tmpdir, "model.ifc") + sha_b = _commit_ifc( + repo, + tmpdir, + "#1=IFCPROJECT('xyz',$,$,$,$,$,$,$,$);\n#2=IFCSITE('t',$,$,$,$,$,$,$,$,$,$,$,$);\n", + "update both", + ) + result = IfcGit.ifc_diff_ids(repo, sha_a, sha_b, ifc_path) + assert 1 in result["modified"] + assert 2 in result["modified"] + + +# --------------------------------------------------------------------------- +# Merge conflict report — store / clear / get +# --------------------------------------------------------------------------- + + +class TestStoreClearGetMergeConflicts(NewFile): + def test_round_trip(self): + conflicts = [{"type": "attribute_conflict", "entity_id": 42}] + IfcGit.store_merge_conflicts(conflicts) + result = IfcGit.get_merge_conflicts() + assert result == conflicts + + def test_get_returns_none_when_empty(self): + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_clear_removes_stored_conflicts(self): + IfcGit.store_merge_conflicts([{"type": "class_changed"}]) + IfcGit.clear_merge_conflicts() + assert IfcGit.get_merge_conflicts() is None + + def test_get_returns_none_on_corrupt_json(self): + import bpy + + bpy.context.scene.IfcGitProperties.merge_conflicts = "not valid json {" + assert IfcGit.get_merge_conflicts() is None + + +# --------------------------------------------------------------------------- +# git_mergetool — report file reading +# --------------------------------------------------------------------------- + + +class TestGitMergetool: + @requires_git + def test_returns_none_when_report_file_absent(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + IfcGitRepo.repo = None + + @requires_git + def test_returns_none_when_report_file_empty(self): + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + open(report_path, "w").close() + mock_repo = mock.MagicMock() + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result is None + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + @requires_git + def test_parses_conflict_report_and_deletes_file(self): + import json + import unittest.mock as mock + + with tempfile.TemporaryDirectory() as tmpdir: + ifc_path = os.path.join(tmpdir, "model.ifc") + report_path = ifc_path + ".ifcmerge" + conflicts = [{"type": "attribute_conflict", "entity_id": 5}] + with open(report_path, "w") as f: + json.dump({"status": "failed", "conflicts": conflicts}, f) + mock_repo = mock.MagicMock() + mock_repo.git.mergetool.side_effect = git.exc.GitCommandError("mergetool", 1) + IfcGitRepo.repo = mock_repo + result = IfcGit.git_mergetool("ifcmerge", ifc_path) + assert result == conflicts + assert not os.path.exists(report_path) + IfcGitRepo.repo = None + + +# --------------------------------------------------------------------------- +# config_ifcmerge — cmd format and update +# --------------------------------------------------------------------------- + + +class TestConfigIfcmerge: + @requires_git + def test_writes_redirect_cmd_on_first_call(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_updates_cmd_missing_redirect(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + with repo.config_writer() as w: + w.set_value('mergetool "ifcmerge"', "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED") + w.set_value('mergetool "ifcmerge"', "trustExitCode", True) + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge"', "cmd") + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None + + @requires_git + def test_forward_tool_writes_redirect_cmd(self): + with tempfile.TemporaryDirectory() as tmpdir: + repo = _make_repo(tmpdir) + IfcGitRepo.repo = repo + IfcGit.config_ifcmerge() + reader = repo.config_reader() + cmd = reader.get_value('mergetool "ifcmerge-forward"', "cmd") + assert "--prioritise-local" in cmd + assert "> $MERGED.ifcmerge" in cmd + IfcGitRepo.repo = None diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 756268cbee..30782b8a15 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -176,6 +176,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.1, 0.4) + pset_data["custom_tread_lock"] = False calculated_data["Length"] += -0.2 + 0.1 self.compare_data(pset_data, calculated_data) @@ -183,6 +184,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.0, None) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each self.compare_data(pset_data, calculated_data) @@ -190,6 +192,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (None, 0.0) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each self.compare_data(pset_data, calculated_data) @@ -197,6 +200,7 @@ class TestStairCalculatedParams(NewFile): pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + pset_data["custom_tread_lock"] = False calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each self.compare_data(pset_data, calculated_data) diff --git a/src/bonsai/test/tool/test_nest.py b/src/bonsai/test/tool/test_nest.py index 14acfa18bc..368ea9a923 100644 --- a/src/bonsai/test/tool/test_nest.py +++ b/src/bonsai/test/tool/test_nest.py @@ -19,6 +19,7 @@ import bpy import ifcopenshell import ifcopenshell.api +import ifcopenshell.api.nest import ifcopenshell.api.spatial import bonsai.core.tool @@ -51,6 +52,42 @@ class TestCanNest(NewFile): subelement_obj = bpy.data.objects.new("Object", None) assert subject.can_nest(element_obj, subelement_obj) is False + def test_element_cannot_nest_to_itself(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + element = ifc.createIfcWall() + element_obj = bpy.data.objects.new("Object", None) + tool.Ifc.link(element, element_obj) + assert subject.can_nest(element_obj, element_obj) is False + + def test_cyclic_nesting_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.createIfcWall() + wall_a_obj = bpy.data.objects.new("WallA", None) + tool.Ifc.link(wall_a, wall_a_obj) + wall_b = ifc.createIfcWall() + wall_b_obj = bpy.data.objects.new("WallB", None) + tool.Ifc.link(wall_b, wall_b_obj) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a) + assert subject.can_nest(wall_b_obj, wall_a_obj) is False + + def test_deep_cyclic_nesting_is_prevented(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + wall_a = ifc.createIfcWall() + wall_a_obj = bpy.data.objects.new("WallA", None) + tool.Ifc.link(wall_a, wall_a_obj) + wall_b = ifc.createIfcWall() + wall_b_obj = bpy.data.objects.new("WallB", None) + tool.Ifc.link(wall_b, wall_b_obj) + wall_c = ifc.createIfcWall() + wall_c_obj = bpy.data.objects.new("WallC", None) + tool.Ifc.link(wall_c, wall_c_obj) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_b], relating_object=wall_a) + ifcopenshell.api.nest.assign_object(ifc, related_objects=[wall_c], relating_object=wall_b) + assert subject.can_nest(wall_c_obj, wall_a_obj) is False + class TestDisableEditing(NewFile): def test_run(self): diff --git a/src/bonsai/test/tool/test_project.py b/src/bonsai/test/tool/test_project.py index ae0a187080..e9e11f4656 100644 --- a/src/bonsai/test/tool/test_project.py +++ b/src/bonsai/test/tool/test_project.py @@ -365,7 +365,7 @@ class TestLoadingIfcSqlite(NewFile): sql_type="SQLite", ) patcher.patch() - tmp_file = Path(tempfile.mktemp(suffix=".ifcsqlite")) + tmp_file = Path(tempfile.mkstemp(suffix=".ifcsqlite")[1]) ifcpatch.write(patcher.get_output(), tmp_file) elements_with_meshes = [ diff --git a/src/bonsai/type-check-requirements.txt b/src/bonsai/type-check-requirements.txt new file mode 100644 index 0000000000..1a8419c7d3 --- /dev/null +++ b/src/bonsai/type-check-requirements.txt @@ -0,0 +1,41 @@ +aiohttp +beautifulsoup4 +boto3 +botocore +brickschema +cjio >=0.8, <0.10 +debugpy +ezdxf +fake-bpy-module-latest +git+https://github.com/prochitecture/bpypolyskel +git+https://github.com/Andrej730/IFC2JSON_python.git@pyproject_toml +gitpython +isodate +lark +lxml +lxml-stubs +markdown-it-py +natsort +numpy +odfpy +openpyxl +pandas +pillow +platformdirs +pygments +pyradiance +pystache +pytest +pytest_bdd +pytest_blender +python-dateutil +python-socketio +pytz +rdflib +requests +shapely +svgwrite +typing-extensions +typst +tzfpy +xsdata diff --git a/src/bsdd/bsdd_json.py b/src/bsdd/bsdd_json.py index becaac810b..6a89d19753 100644 --- a/src/bsdd/bsdd_json.py +++ b/src/bsdd/bsdd_json.py @@ -7,7 +7,7 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, model_validator -from .type_hints import * +from type_hints import * def _lower_first(s: str) -> str: diff --git a/src/common.mk b/src/common.mk index 24537d06de..cbc251fbe2 100644 --- a/src/common.mk +++ b/src/common.mk @@ -1,7 +1,7 @@ SHELL := sh IS_STABLE:=FALSE -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 VERSION:=$(shell cat ../../VERSION) VERSION_DATE:=$(shell date '+%y%m%d') SED:=sed -i @@ -28,6 +28,7 @@ dist: mkdir -p dist cp -r $(PACKAGE_NAME) build/ cp pyproject.toml build/ + if [ -f README.md ]; then cp README.md build/; fi ifeq ($(IS_STABLE), TRUE) $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' build/pyproject.toml ifdef IS_MODULE diff --git a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json index 596f39a2b5..02f6717028 100644 --- a/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json +++ b/src/ifc5d/ifc5d/IFC4X3QtoBaseQuantitiesBlender.json @@ -237,7 +237,7 @@ "Area": "get_net_side_area", "Height": "get_height", "Perimeter": "get_rectangular_perimeter", - "Width": "get_length" + "Width": "get_x" } }, "IfcDuctFitting + IfcDuctFittingType": { diff --git a/src/ifcchat/CNAME b/src/ifcchat/CNAME new file mode 100644 index 0000000000..6fbcc54661 --- /dev/null +++ b/src/ifcchat/CNAME @@ -0,0 +1 @@ +ai-chat.ifcopenshell.org \ No newline at end of file diff --git a/src/ifcchat/README.md b/src/ifcchat/README.md new file mode 100644 index 0000000000..5dc434143a --- /dev/null +++ b/src/ifcchat/README.md @@ -0,0 +1,10 @@ +IfcOpenShell AI Assistant +========================= + +A web-based client-side (pyodide + OpenAI, Anthropic, Gemini, or OpenRouter API) model interrogation and generation API based on: ifcedit, ifcquery and ifcmcp (ifcopenshell-mcp) packaged in a HTML+JS application. + +### Setup instructions + +``` +pip download ifcquery==0.8.5 ifcopenshell-mcp==0.8.5 ifcedit==0.8.5 lark==1.3.1 isodate==0.7.2 --no-deps -d ./dist +``` diff --git a/src/ifcchat/api_anthropic.js b/src/ifcchat/api_anthropic.js new file mode 100644 index 0000000000..6403317154 --- /dev/null +++ b/src/ifcchat/api_anthropic.js @@ -0,0 +1,184 @@ +// This file was generated with the assistance of an AI coding tool. + +function parseArguments(argumentsText) { + if (!argumentsText) return {}; + try { + return JSON.parse(argumentsText); + } catch { + return {}; + } +} + +function toAnthropicTools(tools = []) { + return tools.map((tool) => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters, + })); +} + +function toAnthropicAssistantContent(message) { + const content = []; + + if (message.content) { + content.push({ type: "text", text: message.content }); + } + + for (const toolCall of message.tool_calls ?? []) { + content.push({ + type: "tool_use", + id: toolCall.id, + name: toolCall.function.name, + input: parseArguments(toolCall.function.arguments), + }); + } + + if (content.length === 0) { + return ""; + } + + return content.length === 1 && content[0].type === "text" ? content[0].text : content; +} + +function toAnthropicUserContent(message) { + return typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""); +} + +function toAnthropicToolResult(message) { + return { + type: "tool_result", + tool_use_id: message.tool_call_id, + content: typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""), + }; +} + +function splitSystemAndMessages(messages = []) { + const system = []; + const anthropicMessages = []; + let pendingToolResults = []; + + const flushToolResults = () => { + if (pendingToolResults.length === 0) return; + anthropicMessages.push({ role: "user", content: pendingToolResults }); + pendingToolResults = []; + }; + + for (const message of messages) { + if (message.role === "system") { + if (message.content) { + system.push(message.content); + } + continue; + } + + if (message.role === "tool") { + pendingToolResults.push(toAnthropicToolResult(message)); + continue; + } + + flushToolResults(); + + if (message.role === "user") { + anthropicMessages.push({ + role: "user", + content: toAnthropicUserContent(message), + }); + continue; + } + + if (message.role === "assistant") { + anthropicMessages.push({ + role: "assistant", + content: toAnthropicAssistantContent(message), + }); + } + } + + flushToolResults(); + + return { + system: system.join("\n\n"), + messages: anthropicMessages, + }; +} + +function toChatCompletionResponse(response) { + const text = []; + const toolCalls = []; + + for (const block of response.content ?? []) { + if (block.type === "text") { + text.push(block.text); + continue; + } + + if (block.type === "tool_use") { + toolCalls.push({ + id: block.id, + type: "function", + function: { + name: block.name, + arguments: JSON.stringify(block.input ?? {}), + }, + }); + } + } + + const message = { role: "assistant" }; + const content = text.join("\n").trim(); + + if (content) { + message.content = content; + } + + if (toolCalls.length) { + message.tool_calls = toolCalls; + } + + return { + choices: [ + { + message, + }, + ], + }; +} + +export async function chat({ apiKey, model, messages, tools }) { + const request = splitSystemAndMessages(messages); + const anthropicTools = toAnthropicTools(tools); + + // Mark the last tool with cache_control so the entire tool list is cached + if (anthropicTools.length > 0) { + anthropicTools[anthropicTools.length - 1].cache_control = { type: "ephemeral" }; + } + + const body = { + model, + max_tokens: 4096, + messages: request.messages, + tools: anthropicTools, + }; + + if (request.system) { + body.system = [{ type: "text", text: request.system, cache_control: { type: "ephemeral" } }]; + } + + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Anthropic error ${res.status}: ${text}`); + } + + return toChatCompletionResponse(await res.json()); +} diff --git a/src/ifcchat/api_openai.js b/src/ifcchat/api_openai.js new file mode 100644 index 0000000000..8903f897e4 --- /dev/null +++ b/src/ifcchat/api_openai.js @@ -0,0 +1,20 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://api.openai.com/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} + +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, tools }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenAI error ${res.status}: ${text}`); + } + return await res.json(); +} diff --git a/src/ifcchat/api_openrouter.js b/src/ifcchat/api_openrouter.js new file mode 100644 index 0000000000..38c11e36e8 --- /dev/null +++ b/src/ifcchat/api_openrouter.js @@ -0,0 +1,20 @@ +function getChatCompletionsUrl(baseURL) { + const root = (baseURL || "https://openrouter.ai/api/v1").replace(/\/+$/, ""); + return `${root}/chat/completions`; +} + +export async function chat({ apiKey, baseURL, model, messages, tools }) { + const res = await fetch(getChatCompletionsUrl(baseURL), { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, tools }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OpenRouter error ${res.status}: ${text}`); + } + return await res.json(); +} diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js new file mode 100644 index 0000000000..cf8723f46f --- /dev/null +++ b/src/ifcchat/app.js @@ -0,0 +1,771 @@ +// app.js +import * as openaiApi from "./api_openai.js"; +import * as anthropicApi from "./api_anthropic.js"; +import * as openrouterApi from "./api_openrouter.js"; + +const PROVIDERS = { + openai: { + api: openaiApi, + apiKeyLabel: "OpenAI API key", + apiKeyPlaceholder: "sk-...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://api.openai.com/v1", + baseUrlDefault: "https://api.openai.com/v1", + models: [ + { + value: "gpt-5.2", + label: "gpt-5.2" + }, + { + value: "gpt-5.2-chat-latest", + label: "gpt-5.2-chat-latest" + }, + { + value: "gpt-5", + label: "gpt-5" + }, + { + value: "gpt-5-chat-latest", + label: "gpt-5-chat-latest" + }, + { + value: "gpt-5-mini", + label: "gpt-5-mini" + }, + { + value: "gpt-5-nano", + label: "gpt-5-nano" + }, + { + value: "gpt-4.1", + label: "gpt-4.1" + }, + { + value: "gpt-4.1-mini", + label: "gpt-4.1-mini" + }, + { + value: "gpt-4.1-nano", + label: "gpt-4.1-nano" + }, + ], + }, + anthropic: { + api: anthropicApi, + apiKeyLabel: "Anthropic API key", + apiKeyPlaceholder: "sk-ant-...", + models: [ + { + value: "claude-sonnet-4-6", + label: "claude-sonnet-4-6" + }, + { + value: "claude-opus-4-6", + label: "claude-opus-4-6" + }, + { + value: "claude-haiku-4-5-20251001", + label: "claude-haiku-4-5" + }, + ], + }, + gemini: { + api: openaiApi, + apiKeyLabel: "Gemini API key", + apiKeyPlaceholder: "AIza...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://generativelanguage.googleapis.com/v1beta/openai/", + baseUrlDefault: "https://generativelanguage.googleapis.com/v1beta/openai/", + models: [ + { + value: "gemini-3-flash-preview", + label: "gemini-3-flash-preview" + }, + { + value: "gemini-2.5-flash", + label: "gemini-2.5-flash" + }, + { + value: "gemini-2.5-pro", + label: "gemini-2.5-pro" + }, + ], + }, + openrouter: { + api: openrouterApi, + apiKeyLabel: "OpenRouter API key", + apiKeyPlaceholder: "sk-or-v1-...", + baseUrlLabel: "Base URL", + baseUrlPlaceholder: "https://openrouter.ai/api/v1", + baseUrlDefault: "https://openrouter.ai/api/v1", + models: [ + { + value: "openai/gpt-oss-20b", + label: "gpt-oss-20b" + }, + { + value: "openai/gpt-oss-120b", + label: "gpt-oss-120b" + }, + { + value: "mistralai/mistral-small-3.2-24b-instruct", + label: "mistral-small-3.2" + }, + { + value: "openai/gpt-4.1", + label: "gpt-4.1" + }, + { + value: "anthropic/claude-sonnet-4-5", + label: "claude-sonnet-4-5" + }, + { + value: "google/gemini-2.5-pro-preview", + label: "gemini-2.5-pro" + }, + ], + }, +}; + +const $ = (id) => document.getElementById(id); + +const statusEl = $("status"); +const msgsEl = $("msgs"); +const sendBtn = $("send"); +const inputEl = $("input"); +const apiKeyEl = $("apiKey"); +const apiKeyLabelEl = $("apiKeyLabel"); +const baseUrlRowEl = $("baseUrlRow"); +const baseUrlLabelEl = $("baseUrlLabel"); +const baseUrlEl = $("baseUrl"); +const thinkingIndicatorEl = $("thinkingIndicator"); +const compactingIndicatorEl = $("compactingIndicator"); +const modelEl = $("model"); +const providerEls = document.querySelectorAll('input[name="provider"]'); +const ifcFileEl = $("ifcFile"); +const newBtn = $("newModel"); +const downloadBtn = $("downloadIfc"); + +function getProviderValue() { + return document.querySelector('input[name="provider"]:checked')?.value || "openai"; +} + +function onProviderChange() { + const provider = PROVIDERS[getProviderValue()]; + apiKeyLabelEl.innerHTML = `${provider.apiKeyLabel}stored in browser memory; only sent to provider servers`; + apiKeyEl.placeholder = provider.apiKeyPlaceholder; + baseUrlRowEl.hidden = !provider.baseUrlDefault; + if (provider.baseUrlDefault) { + baseUrlLabelEl.innerHTML = `${provider.baseUrlLabel}override the API endpoint for OpenAI-compatible providers`; + baseUrlEl.placeholder = provider.baseUrlPlaceholder; + baseUrlEl.value = provider.baseUrlDefault; + } else { + baseUrlEl.value = ""; + baseUrlEl.placeholder = ""; + } + modelEl.innerHTML = provider.models.map(m => ``).join(""); +} + +for (const providerEl of providerEls) { + providerEl.addEventListener("change", onProviderChange); +} +onProviderChange(); + +function setBusy(isBusy, reason = "") { + const controls = [ + $("send"), + $("newModel"), + $("downloadIfc"), + $("ifcFile"), + ]; + + for (const el of controls) el.disabled = isBusy; + + $("input").disabled = isBusy; + + const browseBtn = $("browseBtn"); + if (browseBtn) { + browseBtn.classList.toggle("disabled", isBusy); + browseBtn.setAttribute("aria-disabled", isBusy ? "true" : "false"); + browseBtn.tabIndex = isBusy ? -1 : 0; + } + + sendBtn.innerHTML = isBusy + ? `` + : `Send send`; + + setStatus(isBusy ? (reason || "Working…") : "Ready"); +} + +function escapeHtml(text) { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function sanitizeUrl(url) { + try { + const parsed = new URL(url, window.location.href); + if (["http:", "https:", "mailto:"].includes(parsed.protocol)) { + return parsed.href; + } + } catch { + } + return null; +} + +function renderInlineMarkdown(text) { + const placeholders = []; + const addPlaceholder = (html) => { + const token = `@@MD${placeholders.length}@@`; + placeholders.push({ token, html }); + return token; + }; + + let rendered = text; + + rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`${escapeHtml(code)}`)); + rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => { + const href = sanitizeUrl(url); + if (!href) { + return `${label} (${url})`; + } + return addPlaceholder( + `${escapeHtml(label)}` + ); + }); + + rendered = escapeHtml(rendered); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1"); + rendered = rendered.replace(/\*([^*]+)\*/g, "$1"); + rendered = rendered.replace(/_([^_]+)_/g, "$1"); + + for (const placeholder of placeholders) { + rendered = rendered.replaceAll(placeholder.token, placeholder.html); + } + + return rendered; +} + +function renderMarkdown(text) { + const lines = String(text).replace(/\r\n?/g, "\n").split("\n"); + const html = []; + let paragraphLines = []; + let quoteLines = []; + let listType = null; + let listItems = []; + + const flushParagraph = () => { + if (!paragraphLines.length) return; + html.push(`

${renderInlineMarkdown(paragraphLines.join(" "))}

`); + paragraphLines = []; + }; + + const flushQuote = () => { + if (!quoteLines.length) return; + const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("
"); + html.push(`

${quoteBody}

`); + quoteLines = []; + }; + + const flushList = () => { + if (!listItems.length || !listType) return; + const items = listItems.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join(""); + html.push(`<${listType}>${items}`); + listType = null; + listItems = []; + }; + + const flushAll = () => { + flushParagraph(); + flushQuote(); + flushList(); + }; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const trimmed = line.trim(); + + if (trimmed.startsWith("```")) { + flushAll(); + const language = trimmed.slice(3).trim(); + const codeLines = []; + index += 1; + while (index < lines.length && !lines[index].trim().startsWith("```")) { + codeLines.push(lines[index]); + index += 1; + } + const languageClass = language ? ` class="language-${escapeHtml(language)}"` : ""; + html.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); + continue; + } + + if (!trimmed) { + flushAll(); + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/); + if (headingMatch) { + flushAll(); + const level = headingMatch[1].length; + html.push(`${renderInlineMarkdown(headingMatch[2])}`); + continue; + } + + const quoteMatch = trimmed.match(/^>\s?(.*)$/); + if (quoteMatch) { + flushParagraph(); + flushList(); + quoteLines.push(quoteMatch[1]); + continue; + } + + if (quoteLines.length) { + flushQuote(); + } + + const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/); + if (unorderedListMatch) { + flushParagraph(); + if (listType && listType !== "ul") { + flushList(); + } + listType = "ul"; + listItems.push(unorderedListMatch[1]); + continue; + } + + const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/); + if (orderedListMatch) { + flushParagraph(); + if (listType && listType !== "ol") { + flushList(); + } + listType = "ol"; + listItems.push(orderedListMatch[1]); + continue; + } + + if (listItems.length) { + flushList(); + } + + paragraphLines.push(trimmed); + } + + flushAll(); + + return html.join(""); +} + +function addMessage(role, text) { + if (text.ok) { + text = text.data; + } + if (typeof text !== "string") { + text = JSON.stringify(text, null, 2); + } + const wrap = document.createElement("div"); + wrap.className = `msg ${role}`; + wrap.innerHTML = ` +
    ${role}${role === "tool" ? 'ā–¶' : ''}
    +
    `; + const bubble = wrap.querySelector(".bubble"); + if (role === "assistant") { + bubble.classList.add("markdown-content"); + bubble.innerHTML = renderMarkdown(text); + } else { + bubble.textContent = text; + } + bubble.onclick = function () { + if (bubble.scrollHeight > 100 && role === "tool") { + const expanded = bubble.style.maxHeight === 'none'; + bubble.style.maxHeight = expanded ? '' : 'none'; + bubble.style.borderBottom = expanded ? '' : 'dotted 2px gray'; + wrap.querySelector(".chevron").style.transform = expanded ? '' : 'rotate(90deg)'; + } + } + msgsEl.insertBefore(wrap, thinkingIndicatorEl); + msgsEl.scrollTop = msgsEl.scrollHeight; +} + +function setStatus(text) { + statusEl.textContent = text; + thinkingIndicatorEl.hidden = text !== "Thinking…"; + compactingIndicatorEl.hidden = text !== "Compacting…"; + msgsEl.scrollTop = msgsEl.scrollHeight; +} + +const worker = new Worker("./ifc_worker.js", { type: "module" }); + +function callWorker(type, payload = {}) { + return new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + const onMsg = (ev) => { + const msg = ev.data; + if (!msg || msg.id !== id) return; + worker.removeEventListener("message", onMsg); + if (msg.ok) resolve(msg); + else reject(new Error(msg.error || "Worker error")); + }; + worker.addEventListener("message", onMsg); + worker.postMessage({ id, type, payload }); + }); +} + +// ---- Tool schemas (should match ifcmcp.core openai_tools()) ---- +const tools = [ + { + type: "function", function: { name: "ifc_new", description: "Create a new empty IFC model in memory. Valid schemas: IFC4, IFC2X3, IFC4X3 (for IFC 4.3).", + parameters: { type: "object", properties: { schema: { type: "string", enum: ["IFC4", "IFC2X3", "IFC4X3"] } }, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_summary", description: "Get a concise overview of the loaded IFC model.", + parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_tree", description: "Get the full spatial hierarchy tree.", + parameters: { type: "object", properties: {}, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_select", description: "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').", + parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_info", description: "Inspect an entity by STEP id.", + parameters: { type: "object", properties: { element_id: { type: "integer" } }, required: ["element_id"], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_relations", description: "Get relationships for an element. traverse='up' walks to IfcProject.", + parameters: { + type: "object", properties: { element_id: { type: "integer" }, traverse: { type: "string" } }, + required: ["element_id"], additionalProperties: false + } } + }, + { + type: "function", function: { name: "ifc_clash", description: "Run clash/clearance checks for an element.", + parameters: { + type: "object", properties: { element_id: { type: "integer" }, clearance: { type: "number" }, tolerance: { type: "number" }, scope: { type: "string" } }, + required: ["element_id"], additionalProperties: false + } } + }, + { + type: "function", function: { name: "ifc_list", description: "List ifcopenshell.api modules or functions within a module.", + parameters: { type: "object", properties: { module: { type: "string" } }, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_docs", description: "Get documentation for an ifcopenshell.api function, 'module.function'.", + parameters: { type: "object", properties: { function_path: { type: "string" } }, required: ["function_path"], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_edit", description: "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", + parameters: { type: "object", properties: { function_path: { type: "string" }, params: { type: "string" } }, required: ["function_path"], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_validate", description: "Validate the loaded model. Returns valid bool and list of issues.", + parameters: { type: "object", properties: { express_rules: { type: "boolean" } }, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_schedule", description: "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", + parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_cost", description: "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", + parameters: { type: "object", properties: { max_depth: { type: "integer" } }, required: [], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_schema", description: "Return IFC class documentation for an entity type.", + parameters: { type: "object", properties: { entity_type: { type: "string" } }, required: ["entity_type"], additionalProperties: false } } + }, + { + type: "function", function: { name: "ifc_quantify", description: "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", + parameters: { type: "object", properties: { rule: { type: "string" }, selector: { type: "string" } }, required: ["rule"], additionalProperties: false } } + }, +]; + +const SYSTEM_INSTRUCTIONS = ` +You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model. +Rules: +- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree. +- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit. +- If there is no model and the user wants to create one, call ifc_new. +- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement). +- After edits, explain what changed and suggest downloading the IFC. +Be concise. Avoid dumping huge trees unless asked. +`; + +let messages = []; // running conversation state (Chat Completions style) + +const MAX_TOOL_RESULT_CHARS = 0; +const MAX_HISTORY_MESSAGES = 40; +const ESTIMATED_CHARS_PER_TOKEN = 4; +const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000; +const COMPACT_WHEN_ESTIMATED_TOKENS = 18000; +const KEEP_RAW_TURN_GROUPS = 1; +const minuteTokenMap = new Map(); + +function truncateToolResult(text) { + if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text; + return text.slice(0, MAX_TOOL_RESULT_CHARS) + "\n... (truncated)"; +} + +function trimHistory() { + if (messages.length <= MAX_HISTORY_MESSAGES) return; + // Find a safe cut point — don't break mid-tool-call sequence. + // Walk forward from the trim target to find a user message boundary. + let cut = messages.length - MAX_HISTORY_MESSAGES; + while (cut < messages.length && messages[cut].role !== "user") { + cut++; + } + if (cut > 0 && cut < messages.length) { + messages.splice(0, cut); + } +} + +function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) { + return Array.from(minuteTokenMap.entries()) + .filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket) + .sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket) + .map(([minuteBucket, estimatedTokens]) => ({ + timestamp: new Date(minuteBucket * 60000).toISOString(), + estimated_tokens: estimatedTokens, + })); +} + +async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN) + ); + let currentMinuteBucket = Math.floor(Date.now() / 60000); + const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; + + if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { + currentMinuteBucket += 1; + await new Promise((resolve) => setTimeout(() => resolve(), 60000)); + } + + minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); + + return { + minuteBucket: currentMinuteBucket, + response: await chat({ apiKey, baseURL, model, messages, tools }), + }; +} + +async function compactHistoryWithLLM(chat, apiKey, baseURL, model) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ); + if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null; + + const { prefix, groups } = messages.reduce((acc, message) => { + if (message.role === "user") { + acc.groups.push([message]); + } else if (acc.groups.length) { + acc.groups[acc.groups.length - 1].push(message); + } else { + acc.prefix.push(message); + } + return acc; + }, { prefix: [], groups: [] }); + + if (groups.length <= KEEP_RAW_TURN_GROUPS) return null; + + const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()]; + if (!compacted.length) return null; + + setStatus("Compacting…"); + try { + const before = { + message_count: messages.length, + turn_group_count: groups.length, + estimated_tokens: estimatedTokens, + }; + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, + apiKey, + baseURL, + model, + messages: [ + { + role: "system", + content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary." + }, + { role: "user", content: JSON.stringify(compacted) }, + ], + }); + const summary = response.choices?.[0]?.message?.content?.trim(); + + if (!summary) return minuteBucket; + + messages = [ + { role: "assistant", content: `[Context summary]\n${summary}` }, + ...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(), + ]; + console.log("History compaction before", before); + console.log("History compaction after", { + message_count: messages.length, + turn_group_count: messages.filter((message) => message.role === "user").length, + estimated_tokens: Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ), + }); + return minuteBucket; + } finally { + setStatus("Thinking…"); + } +} + +async function runAgentTurn(userText) { + const apiKey = apiKeyEl.value.trim(); + if (!apiKey) throw new Error("Missing API key"); + + const provider = PROVIDERS[getProviderValue()]; + const { chat } = provider.api; + const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; + let firstIterationMinuteBucket = null; + + messages.push({ role: "user", content: userText }); + + for (let i = 0; i < 64; i++) { + const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value); + if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) { + firstIterationMinuteBucket = compactedMinuteBucket; + } + if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory(); + + const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]; + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, + apiKey, + baseURL, + model: modelEl.value, + messages: messages_with_system, + tools, + }); + if (firstIterationMinuteBucket === null) { + firstIterationMinuteBucket = minuteBucket; + } + + const message = response.choices?.[0]?.message; + if (!message) throw new Error("No message in response"); + + messages.push(message); + + if (message.content) addMessage("assistant", message.content); + + const calls = message.tool_calls ?? []; + if (calls.length === 0) { + console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket)); + return; + } + + for (const call of calls) { + let args = {}; + try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } + catch { args = {}; } + + addMessage("tool", `→ ${call.function.name}(${JSON.stringify(args)})`); + + const toolRes = await callWorker("toolCall", { name: call.function.name, args }); + + const fullResult = JSON.stringify(toolRes.result); + + messages.push({ + role: "tool", + tool_call_id: call.id, + content: truncateToolResult(fullResult), + }); + + // Show full result in UI, but only truncated version goes to the LLM + addMessage("tool", `← ${call.function.name}: ${JSON.stringify(toolRes.result, null, 2)}`); + } + } + + addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request."); +} + +sendBtn.onclick = async () => { + const text = inputEl.value.trim(); + if (!text) return; + inputEl.value = ""; + addMessage("user", text); + try { + setBusy(true, "Thinking…"); + await runAgentTurn(text); + setBusy(false, "Ready"); + } catch (e) { + setBusy(false, "Error"); + addMessage("assistant", `Error: ${e.message}`); + } +}; + +inputEl.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendBtn.click(); + } +}); + +ifcFileEl.onchange = async () => { + const f = ifcFileEl.files?.[0]; + if (!f) return; + setBusy(true, "Loading IFC into Pyodide…"); + const buf = await f.arrayBuffer(); + try { + const r = await callWorker("loadIfc", { filename: f.name, bytes: buf }, [buf]); + addMessage("assistant", r.result); + setBusy(false, "Ready"); + } catch (e) { + setStatus(true, "Error"); + addMessage("assistant", `Load error: ${e.message}`); + } +}; + +newBtn.onclick = async () => { + try { + setBusy(true, "Creating new model…"); + const r = await callWorker("toolCall", { name: "ifc_new", args: { schema: "IFC4X3" } }); + addMessage("assistant", `New model: ${JSON.stringify(r.result)}`); + setBusy(false, "Ready"); + } catch (e) { + setBusy(true, "Error"); + addMessage("assistant", `Error: ${e.message}`); + } +}; + +downloadBtn.onclick = async () => { + try { + setBusy(true, "Exporting IFC…"); + const r = await callWorker("exportIfc", {}); + const blob = new Blob([r.bytes], { type: "application/octet-stream" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = r.filename || "model.ifc"; + a.click(); + URL.revokeObjectURL(url); + setBusy(false, "Ready"); + } catch (e) { + setBusy(true, "Error"); + addMessage("assistant", `Export error: ${e.message}`); + } +}; + +(async () => { + try { + setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…"); + await callWorker("init", {}); + setBusy(false, "Ready"); + } catch (e) { + setBusy(true, "Error"); + addMessage("assistant", `Worker init failed: ${e.message}`); + } +})(); diff --git a/src/ifcchat/ifc_worker.js b/src/ifcchat/ifc_worker.js new file mode 100644 index 0000000000..06d6ecdf70 --- /dev/null +++ b/src/ifcchat/ifc_worker.js @@ -0,0 +1,101 @@ +// ifc_worker.js (MODULE WORKER) +import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.mjs"; + +let pyodide = null; +let callToolPy = null; +let initPromise = null; + +function ok(id, extra = {}, transfer = []) { + self.postMessage({ id, ok: true, ...extra }, transfer); +} +function fail(id, error) { + self.postMessage({ id, ok: false, error: String(error?.message || error) }); +} + +async function ensurePyodide() { + if (initPromise) return initPromise; + + initPromise = (async () => { + // Passing indexURL avoids some environments failing to infer it from the module URL. :contentReference[oaicite:3]{index=3} + pyodide = await loadPyodide({ + indexURL: "https://cdn.jsdelivr.net/pyodide/v0.29.3/full/", + }); + + await pyodide.loadPackage("micropip"); + await pyodide.loadPackage("numpy"); + await pyodide.loadPackage("shapely"); + await pyodide.loadPackage("typing-extensions"); + + const micropip = pyodide.pyimport("micropip"); + micropip.install("python-dateutil") + + const wheelUrl = "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.5-cp313-cp313-pyodide_2025_0_wasm32.whl"; + + await micropip.install(wheelUrl); + + await micropip.install([ + "./dist/ifcquery-0.8.5-py3-none-any.whl", + "./dist/ifcedit-0.8.5-py3-none-any.whl", + "./dist/ifcopenshell_mcp-0.8.5-py3-none-any.whl", + "./dist/lark-1.3.1-py3-none-any.whl", + "./dist/isodate-0.7.2-py3-none-any.whl", + ]) + await pyodide.runPythonAsync(` +from ifcmcp.embedded import call_tool as _call_tool + `); + callToolPy = pyodide.globals.get("_call_tool"); + })(); + + return initPromise; +} + +function callTool(name, args) { + const pyArgs = pyodide.toPy(args); + const res = callToolPy(name, pyArgs); + pyArgs.destroy(); + const resJs = res.toJs({ dict_converter: Object.fromEntries }); + res.destroy(); + return resJs; +} + +self.onmessage = async (ev) => { + const { id, type, payload } = ev.data || {}; + try { + if (type === "init") { + await ensurePyodide(); + ok(id, { result: "ok" }); + return; + } + + await ensurePyodide(); + + if (type === "loadIfc") { + const { filename, bytes } = payload; + const path = `/tmp/${filename || "model.ifc"}`; + pyodide.FS.mkdirTree("/tmp"); + pyodide.FS.writeFile(path, new Uint8Array(bytes)); + const result = callTool("ifc_load", { path }); + ok(id, { result }); + return; + } + + if (type === "exportIfc") { + const path = "/tmp/export.ifc"; + const result = callTool("ifc_save", { path }); + const data = pyodide.FS.readFile(path); + ok(id, { result, filename: "export.ifc", bytes: data }, [data.buffer]); + return; + } + + if (type === "toolCall") { + const { name, args } = payload; + const result = callTool(name, args || {}); + ok(id, { result }); + return; + } + + throw new Error(`Unknown message type: ${type}`); + } catch (e) { + fail(id, e); + } +}; \ No newline at end of file diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html new file mode 100644 index 0000000000..7ccb678591 --- /dev/null +++ b/src/ifcchat/index.html @@ -0,0 +1,131 @@ + + + + + + + IfcOpenShell AI Assistant + + + + + +
    +
    +
    + +
    + + + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + +
    + + + +
    + + +
    + +
    + +
    + +
    + +
    + +
    + • Upload an IFC, then ask ā€œSummarize the modelā€ or ā€œList all IfcWallsā€.
    + • Try ā€œAdd a new site and building named Xā€ (will use ifc_edit). +
    +
    + +
    + +
    + Chat with your IFC model. This app uses ifcopenshell-mcp to interact with an IFC model. IFC is in its raw-serialized form provides little textual context on the meaning of attributes, the intent behind element instances and the resulting geometrical form. By using the IfcOpenShell high-level API through an MCP-like interface (Model Context Protocol; allows for structured interaction with APIs and tools) these limitations are addressed and the model can be interrogated on a higher semantic abstraction level. You can also use ifcopenshell-mcp on your local machine with any LLM provider that supports the MCP protocol. +
    +
    +
    + +
    +
    + +
    + IfcOpenShell AI Assistant +
    +
    + + Status: Booting… +
    +
    + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + + + + diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css new file mode 100644 index 0000000000..105e0d574f --- /dev/null +++ b/src/ifcchat/style.css @@ -0,0 +1,370 @@ +* { + box-sizing: border-box; +} + +body { + font-family: system-ui, sans-serif; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +header { + padding: 12px 16px; + border-bottom: 1px solid #ddd; +} + +header input, +header select { + padding: 8px; +} + +main { + display: grid; + grid-template-columns: 320px 1fr; + height: 100vh; +} + +.side { + border-right: 1px solid #ddd; + padding: 12px; + overflow: auto; + background: #f9f9f9; +} + +.chat { + display: flex; + flex-direction: column; + height: 100%; +} + +.msgs { + flex: 1; + overflow: auto; + padding: 16px; +} + +.msg { + margin: 10px 0; +} + +.thinking-indicator { + display: inline-flex; + align-items: center; + gap: 10px; + color: #555; + font-size: 80%; +} + +.thinking-indicator[hidden] { + display: none; +} + +.thinking-indicator .spinner { + width: 14px; + height: 14px; +} + +.msg .role { + font-size: 12px; + opacity: 0.7; + margin: 12px 0 4px 0; +} + +.role.user { + text-align: right; +} + +.msg .bubble { + padding: 0; + border-radius: 10px; + white-space: pre-wrap; +} + +.msg.assistant .bubble { + padding: 10px 14px; + line-height: 1.5; +} + +.markdown-content > :first-child { + margin-top: 0; +} + +.markdown-content > :last-child { + margin-bottom: 0; +} + +.markdown-content p, +.markdown-content ul, +.markdown-content ol, +.markdown-content blockquote, +.markdown-content pre { + margin: 0 0 12px 0; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4, +.markdown-content h5, +.markdown-content h6 { + margin: 0 0 12px 0; + line-height: 1.25; +} + +.markdown-content ul, +.markdown-content ol { + padding-left: 24px; +} + +.markdown-content blockquote { + margin-left: 0; + padding-left: 12px; + border-left: 3px solid #ddd; + color: #555; +} + +.markdown-content code { + padding: 1px 4px; + border-radius: 4px; + background: #f2f2f2; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 90%; +} + +.markdown-content pre { + overflow-x: auto; + padding: 12px; + border-radius: 10px; + background: #f4f4f4; +} + +.markdown-content pre code { + padding: 0; + background: transparent; +} + +.markdown-content a { + color: inherit; +} + +.msg.user .bubble { + padding: 10px 20px; + background: #eee; + width: 50%; + margin-left: auto; + border: solid 1px #ddd; +} + +.msg.tool .bubble { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 60%; + max-height: 100px; + overflow: hidden; + cursor: pointer; +} + +.chevron { + display: inline-block; + font-size: 10px; + margin-left: 6px; + transition: transform 0.2s; + vertical-align: middle; +} + +.composer { + display: flex; + gap: 8px; + padding: 12px; + justify-content: center; +} + +.composer textarea { + flex: 1; + resize: none; + height: 88px; + border: none; +} + +.composer button { + align-self: center; +} + +.composer .inner { + border: solid 1px #ddd; + border-radius: 20px; + padding: 10px; + display: flex; + width: 100%; +} + +@keyframes spin { to { transform: rotate(360deg); } } +.spinner { + width: 18px; + height: 18px; + border: 2px solid #aaa; + border-top-color: #333; + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} + +.status { + font-size: 12px; + opacity: 0.7; +} + +section > .row > label { + display: block; + font-size: 12px; + opacity: 0.75; + margin-bottom: 6px; + font-weight: bold; +} + +section > .row > label .small { + font-weight: normal; + display: block; + font-size: 80%; +} + +.side .row { + margin-bottom: 20px; +} + +.provider-tabs { + display: flex; + flex-wrap: wrap; + gap: 6px; + background: #00000010; + padding: 6px 0 0 6px; +} + +.provider-tab { + position: relative; + display: inline-flex; +} + +.provider-tab input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.provider-tab span { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 38px; + padding: 8px; + border: 1px solid #d0d0d0; + border-radius: 2px 2px 0 0; + background: #e8e8e8; + color: #555; + cursor: pointer; + user-select: none; + transition: background 0.15s, border-color 0.15s, color 0.15s; + font-size: 75%; +} + +.provider-tab input:checked + span { + background: #f9f9f9; + border-color: #999; + color: #111; + border-bottom: none; +} + +.provider-tab input:focus-visible + span { + outline: 2px solid #666; + outline-offset: 2px; +} + +.row button { + padding: 8px 10px; +} + + +hr { + border: dashed 1px #ddd; +} + +.btn-row { + display: flex; + gap: 10px; +} + +.btn-row > .btn, +.btn-row > button.btn { + flex: 1 1 0; + min-width: 0; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + + padding: 10px 12px; + border-radius: 10px; + border: 1px solid #d0d0d0; + background: #eee; + + cursor: pointer; + user-select: none; + text-decoration: none; +} + +.btn:hover { + background: #ddd; +} + +.btn:active { + transform: translateY(1px); +} + +.btn-wide { + width: 100%; +} + +.material-icons { + font-size: 18px; + line-height: 1; +} + +.btn.disabled, +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; + pointer-events: none; +} + +#input { + border: none; + outline: none; +} + +#input:focus, +#input:focus-visible { + outline: none; + box-shadow: none; +} + +.code { + font-family: 'Courier New', Courier, monospace; + font-size: 90%; + background-color: #eee; + border: solid 1px #ddd; + padding: 2px; + display: inline-block; +} + +header .row:nth-child(2) { + padding: 15px 0 0 0; +} \ No newline at end of file diff --git a/src/ifcedit/Makefile b/src/ifcedit/Makefile new file mode 100644 index 0000000000..114e5f9ddc --- /dev/null +++ b/src/ifcedit/Makefile @@ -0,0 +1,10 @@ +PACKAGE_NAME:=ifcedit +include ../common.mk + +.PHONY: test +test: + pytest tests + +.PHONY: qa +qa: + black . diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md new file mode 100644 index 0000000000..19b1ec8e7a --- /dev/null +++ b/src/ifcedit/README.md @@ -0,0 +1,305 @@ + +# ifcedit + +A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as +shell commands. Functions are auto-discovered at runtime via introspection -- +no hardcoded list to maintain. + +## Installation + +```bash +pip install ifcedit +``` + +Requires `ifcopenshell`. + +## Usage + +``` +ifcedit [options] [--format json|text] +``` + +Three subcommands: `list` to discover functions, `docs` to read their +documentation, and `run` to execute them. + +## Subcommands + +### list + +Discover available API modules and their functions. + +**List all modules:** + +```bash +ifcedit list +``` + +```json +[ + { + "module": "root", + "description": "Functions for creating project-level entities", + "functions": ["create_entity", "remove_product", "copy_class"], + "count": 3 + }, + { + "module": "spatial", + "description": "Functions for managing spatial relationships", + "functions": ["assign_container", "unassign_container"], + "count": 2 + } +] +``` + +**List functions in a module:** + +```bash +ifcedit list root +``` + +```json +[ + { + "name": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "params": [ + {"name": "ifc_class", "type": "str", "required": true}, + {"name": "name", "type": "Optional[str]"} + ] + } +] +``` + +### docs + +Show full documentation for a specific function, including parameter +descriptions from docstrings and return type. + +```bash +ifcedit docs root.create_entity +``` + +```json +{ + "module": "root", + "function": "create_entity", + "description": "Create an IFC entity with optional initial attributes", + "long_description": "This function creates a new entity instance...", + "params": [ + { + "name": "ifc_class", + "type": "str", + "required": true, + "description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')" + }, + { + "name": "name", + "type": "Optional[str]", + "description": "Optional name attribute" + } + ], + "return_type": "ifcopenshell.entity_instance", + "return_description": "The newly created entity instance" +} +``` + +### run + +Execute an API function against an IFC file. Parameters are passed as +`--key value` pairs after the function name. + +```bash +ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall" +``` + +```json +{ + "ok": true, + "result": {"id": 42, "type": "IfcWall", "name": "My Wall"} +} +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input +- `--dry-run` -- validate parameters without executing or saving + +```bash +# Save to a new file +ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall + +# Validate without executing +ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall +``` + +Dry-run output shows the resolved parameters: + +```json +{ + "ok": true, + "dry_run": true, + "module": "root", + "function": "create_entity", + "args": {"ifc_class": "IfcWall", "name": "My Wall"} +} +``` + +## Parameter type coercion + +CLI strings are automatically converted to the types expected by each API +function, using the function's type annotations: + +| Type | CLI input | Python value | +|------|-----------|--------------| +| `str` | `"hello"` | `"hello"` | +| `int` | `"42"` or `"#42"` | `42` | +| `float` | `"3.14"` | `3.14` | +| `bool` | `"true"`, `"1"`, `"yes"` | `True` | +| `Optional[X]` | `"none"` | `None` | +| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `Literal["A", "B"]` | `"A"` | validated against allowed values | + +## Examples + +```bash +# Create a project +ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project" + +# Assign an element to a storey +ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4 + +# Assign multiple elements at once +ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1 + +# Add a property set +ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon" + +# Edit properties +ifcedit run model.ifc pset.edit_pset --pset 15 \ + --properties '{"IsExternal": true, "FireRating": "2HR"}' +``` + +### foreach + +Apply an API function to each element in a JSON array read from stdin. +`{field}` placeholders in argument values are substituted with fields from +each JSON object. The model is opened once and saved once regardless of how +many elements are processed. + +```bash +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + +```json +{"ok": true, "count": 36, "errors": []} +``` + +Placeholder tokens match the fields emitted by `ifcquery` — typically `{id}`, +`{type}`, and `{name}`: + +```bash +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' +``` + +**Options:** + +- `-o, --output ` -- write to a different file instead of overwriting the input + +**Output:** + +- `count` -- number of elements successfully processed +- `errors` -- list of per-element failures, each with `index`, `item`, and `error`; processing continues past errors + +```json +{ + "ok": false, + "count": 34, + "errors": [ + {"index": 2, "item": {"id": 55, "type": "IfcWindow", "name": "W03"}, "error": "Entity #55 not found in model"} + ] +} +``` + +Exit code is 1 if any element failed. + +### quantify + +Run quantity take-off (QTO) on an IFC file, computing physical measurements +(volume, area, length, count, weight) and writing them back as +`IfcElementQuantity` property sets. Uses `ifc5d` rules. + +**List available rules:** + +```bash +ifcedit quantify list +``` + +```json +[ + {"name": "IFC4QtoBaseQuantities"}, + {"name": "IFC4X3QtoBaseQuantities"} +] +``` + +**Run QTO on a file:** + +```bash +ifcedit quantify run model.ifc IFC4QtoBaseQuantities +ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall +ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc +``` + +```json +{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42} +``` + +Options: + +- `--selector ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`) +- `-o, --output ` -- write to a different file instead of overwriting the input + +Note: `quantify run` writes geometry-based measurements and requires the +IfcOpenShell C++ geometry bindings for elements with computed quantities. + +## Error handling + +Errors are reported in the JSON response: + +```json +{ + "ok": false, + "error": "Entity #999 not found in model" +} +``` + +Exit code is 0 on success, 1 on error. + +## Relationship to ifcquery + +`ifcedit` and `ifcquery` are complementary tools: + +- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render) +- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify` + +A typical workflow: inspect with `ifcquery`, look up the right API function +with `ifcedit docs`, then apply changes with `ifcedit run`. + +The two tools also compose directly in shell scripts. Use `ifcquery --format ids` +to feed a list of IDs into a `run` parameter, or pipe `ifcquery select` JSON +into `ifcedit foreach` to apply an operation to every matching element: + +```bash +# Aggregate — pass all IDs as a list parameter +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Fan-out — one operation per element, model opened and saved once +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} +``` + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py new file mode 100644 index 0000000000..eac2eac799 --- /dev/null +++ b/src/ifcedit/ifcedit/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py new file mode 100644 index 0000000000..28292f378e --- /dev/null +++ b/src/ifcedit/ifcedit/__main__.py @@ -0,0 +1,265 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +from __future__ import annotations + +import argparse +import json +import sys + +import ifcopenshell + +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.foreach import run_foreach +from ifcedit.quantify import list_rules, run_quantify +from ifcedit.run import run_api + + +def format_output(data, fmt: str) -> str: + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + elif fmt == "text": + return _format_text(data) + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _format_text(data, indent: int = 0) -> str: + prefix = " " * indent + lines = [] + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(_format_text(value, indent + 1)) + else: + lines.append(f"{prefix}{key}: {value}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + lines.append(_format_text(item, indent)) + lines.append("") + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{data}") + return "\n".join(lines) + + +def cmd_list(args): + if args.module: + try: + functions = list_functions(args.module) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(functions, args.output_format)) + else: + modules = list_modules() + print(format_output(modules, args.output_format)) + + +def cmd_docs(args): + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + try: + docs = function_docs(module, function) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + print(format_output(docs, args.output_format)) + + +def cmd_run(args, extra_args): + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + # Parse extra --key value arguments into a dict + raw_kwargs = _parse_extra_args(extra_args) + + if args.dry_run: + result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs} + else: + result = run_api(model, module, function, raw_kwargs) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + +def _parse_extra_args(extra: list[str]) -> dict[str, str]: + """Parse a list of ['--key', 'value', ...] into a dict.""" + kwargs = {} + i = 0 + while i < len(extra): + arg = extra[i] + if arg.startswith("--"): + key = arg[2:] + if i + 1 < len(extra) and not extra[i + 1].startswith("--"): + kwargs[key] = extra[i + 1] + i += 2 + else: + # Flag without value — treat as "true" + kwargs[key] = "true" + i += 1 + else: + print(f"Error: Unexpected argument: {arg}", file=sys.stderr) + sys.exit(1) + return kwargs + + +def cmd_foreach(args, extra_args): + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + parts = args.function_path.split(".") + if len(parts) != 2: + print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr) + sys.exit(1) + module, function = parts + + raw_kwargs_template = _parse_extra_args(extra_args) + + try: + stdin_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Could not parse JSON from stdin: {e}", file=sys.stderr) + sys.exit(1) + + if not isinstance(stdin_data, list): + print("Error: stdin must be a JSON array", file=sys.stderr) + sys.exit(1) + + result = run_foreach(model, module, function, raw_kwargs_template, stdin_data) + + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + + +def cmd_quantify(args, extra_args): + if args.quantify_command == "list": + result = list_rules() + print(format_output(result, args.output_format)) + elif args.quantify_command == "run": + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + selector = args.selector or None + result = run_quantify(model, args.rule_name, selector=selector) + if result["ok"]: + output_path = args.output or args.ifc_file + model.write(output_path) + print(format_output(result, args.output_format)) + if not result["ok"]: + sys.exit(1) + else: + print("Error: quantify requires a subcommand: list or run", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcedit", + description="CLI wrapper for ifcopenshell.api IFC model mutation functions", + ) + parser.add_argument( + "--format", + choices=["json", "text"], + default="json", + dest="output_format", + help="Output format (default: json)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + # list + list_parser = subparsers.add_parser("list", help="List API modules or functions in a module") + list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)") + + # docs + docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function") + docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + + # run + run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file") + run_parser.add_argument("ifc_file", help="Path to the IFC file") + run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)") + run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving") + + # foreach + foreach_parser = subparsers.add_parser( + "foreach", + help="Apply an API function to each element in a JSON array read from stdin", + ) + foreach_parser.add_argument("ifc_file", help="Path to the IFC file") + foreach_parser.add_argument("function_path", help="module.function (e.g. attribute.edit_attributes)") + foreach_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + + # quantify + quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules") + quantify_sub = quantify_parser.add_subparsers(dest="quantify_command") + quantify_sub.add_parser("list", help="List available QTO rule names") + qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file") + qrun_parser.add_argument("ifc_file", help="Path to the IFC file") + qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)") + qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)") + qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)") + + args, extra = parser.parse_known_args() + + if args.command == "list": + cmd_list(args) + elif args.command == "docs": + cmd_docs(args) + elif args.command == "run": + cmd_run(args, extra) + elif args.command == "foreach": + cmd_foreach(args, extra) + elif args.command == "quantify": + cmd_quantify(args, extra) + + +if __name__ == "__main__": + main() diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py new file mode 100644 index 0000000000..5a410ec186 --- /dev/null +++ b/src/ifcedit/ifcedit/coerce.py @@ -0,0 +1,180 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +from __future__ import annotations + +import json +import typing + +import ifcopenshell + + +def coerce_value( + value_str: str, + type_hint, + model: ifcopenshell.file | None = None, + lookup_file: ifcopenshell.file | None = None, +): + """Convert a CLI string argument to the proper Python type based on a type hint. + + Args: + value_str: The raw string from the CLI. + type_hint: The type annotation from the function signature. + model: The main open IFC model, needed to resolve entity instance references by ID. + lookup_file: Override file for entity resolution (e.g. a library file for + project.append_asset). When provided, entity IDs are looked up here instead + of in model. + + Returns: + The converted Python value. + + Raises: + ValueError: If the value cannot be converted. + TypeError: If the type hint is not supported. + """ + # When a library file has been opened, entity IDs are resolved from it, not the main model. + effective_lookup = lookup_file if lookup_file is not None else model + + if type_hint is None: + return value_str + + origin = typing.get_origin(type_hint) + args = typing.get_args(type_hint) + + # Union / Optional + if origin is typing.Union: + non_none_types = [a for a in args if a is not type(None)] + if value_str.lower() == "none": + if type(None) in args: + return None + # Try each non-None type in order + for t in non_none_types: + try: + return coerce_value(value_str, t, model, lookup_file) + except (ValueError, TypeError): + continue + raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}") + + # Literal + if origin is typing.Literal: + allowed = args + if value_str in [str(a) for a in allowed]: + # return the actual literal value with proper type + for a in allowed: + if str(a) == value_str: + return a + raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}") + + # list types + if origin is list: + if args and _is_entity_type(args[0]): + return _coerce_entity_list(value_str, effective_lookup) + if args: + items = _split_list(value_str) + return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items] + return _split_list(value_str) + + # dict types + if origin is dict: + return _floatify_numeric_lists(json.loads(value_str)) + + # Simple types + if type_hint is str: + return value_str + if type_hint is int: + return int(value_str.lstrip("#")) + if type_hint is float: + return float(value_str) + if type_hint is bool: + return value_str.lower() in ("true", "1", "yes") + + # ifcopenshell.file — open from path string + if type_hint is ifcopenshell.file: + return ifcopenshell.open(value_str) + + # entity_instance + if _is_entity_type(type_hint): + return _coerce_entity(value_str, effective_lookup) + + # Fallback: try json.loads for complex types, then plain string + try: + return json.loads(value_str) + except (json.JSONDecodeError, TypeError): + return value_str + + +def _is_entity_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.entity_instance.""" + if hint is ifcopenshell.entity_instance: + return True + if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance): + return True + return False + + +def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance: + """Resolve a step ID string like '123' or '#123' to an entity instance.""" + if lookup_file is None: + raise ValueError("Cannot resolve entity reference without an IFC model") + if isinstance(value_str, int): + entity_id = value_str + else: + entity_id = int(value_str.strip().lstrip("#")) + try: + return lookup_file.by_id(entity_id) + except RuntimeError: + raise ValueError(f"Entity #{entity_id} not found in model") + + +def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]: + """Resolve a comma-separated list of step IDs to entity instances.""" + items = _split_list(value_str) + return [_coerce_entity(item.strip(), lookup_file) for item in items] + + +def _floatify_numeric_lists(obj): + """Recursively convert lists of numbers to lists of floats. + + IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE + attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints + for whole numbers like 0, which causes a TypeError at the binding level. + """ + if isinstance(obj, dict): + return {k: _floatify_numeric_lists(v) for k, v in obj.items()} + if ( + isinstance(obj, list) + and obj + and all(isinstance(v, (int, float)) for v in obj) + and any(isinstance(v, float) for v in obj) + ): + return [float(v) for v in obj] + return obj + + +def _split_list(value_str: str) -> list[str]: + """Split a comma-separated string, handling JSON arrays too.""" + value_str = value_str.strip() + if value_str.startswith("["): + try: + parsed = json.loads(value_str) + if isinstance(parsed, list): + return [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed] + except json.JSONDecodeError: + pass + return [item.strip() for item in value_str.split(",") if item.strip()] diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py new file mode 100644 index 0000000000..b26a93f6c6 --- /dev/null +++ b/src/ifcedit/ifcedit/discover.py @@ -0,0 +1,282 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import re +import typing +from pathlib import Path + + +def _api_package_path() -> Path: + """Return the filesystem path to the ifcopenshell.api package.""" + import ifcopenshell.api + + return Path(ifcopenshell.api.__file__).parent + + +def list_modules() -> list[dict]: + """List all API modules with their function counts and descriptions. + + Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...] + """ + api_path = _api_package_path() + modules = [] + for child in sorted(api_path.iterdir()): + if not child.is_dir() or child.name.startswith("_"): + continue + init_file = child / "__init__.py" + if not init_file.exists(): + continue + try: + mod = importlib.import_module(f"ifcopenshell.api.{child.name}") + except Exception: + continue + all_names = getattr(mod, "__all__", []) + if not all_names: + continue + description = "" + if mod.__doc__: + description = mod.__doc__.strip().split("\n")[0] + modules.append( + { + "module": child.name, + "description": description, + "functions": list(all_names), + "count": len(all_names), + } + ) + return modules + + +def list_functions(module: str) -> list[dict]: + """List functions in an API module with one-line descriptions and parameter info. + + Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}] + """ + mod = importlib.import_module(f"ifcopenshell.api.{module}") + all_names = getattr(mod, "__all__", []) + functions = [] + for name in all_names: + fn = _get_underlying_function(module, name) + if fn is None: + continue + description = "" + if fn.__doc__: + description = fn.__doc__.strip().split("\n")[0] + params = _extract_params(fn) + functions.append( + { + "name": name, + "description": description, + "params": params, + } + ) + return functions + + +def function_docs(module: str, function: str) -> dict: + """Full documentation for a single API function. + + Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type + """ + fn = _get_underlying_function(module, function) + if fn is None: + raise ValueError(f"Function '{module}.{function}' not found") + + description = "" + long_description = "" + if fn.__doc__: + description, long_description = _parse_docstring_body(fn.__doc__) + + params = _extract_params(fn) + param_descriptions = _parse_param_docs(fn.__doc__ or "") + for param in params: + if param["name"] in param_descriptions: + param["description"] = param_descriptions[param["name"]] + + return_type = _format_type_hint(typing.get_type_hints(fn).get("return")) + return_description = _parse_return_doc(fn.__doc__ or "") + + result = { + "module": module, + "function": function, + "description": description, + "long_description": long_description, + "params": params, + } + if return_type: + result["return_type"] = return_type + if return_description: + result["return_description"] = return_description + return result + + +def _get_underlying_function(module: str, function: str): + """Get the actual function object (unwrapping the listener wrapper if needed).""" + try: + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function, None) + return fn + except (ImportError, AttributeError): + return None + + +def _extract_params(fn) -> list[dict]: + """Extract parameter info from a function's signature and type hints.""" + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + params = [] + for name, param in sig.parameters.items(): + if name == "file" or name == "self": + continue + info = {"name": name} + if name in hints: + info["type"] = _format_type_hint(hints[name]) + if param.default is not inspect.Parameter.empty: + info["default"] = _serialize_default(param.default) + else: + info["required"] = True + params.append(info) + return params + + +def _format_type_hint(hint) -> str | None: + """Format a type hint to a readable string.""" + import ifcopenshell + + if hint is None: + return None + if hint is type(None): + return "None" + # ifcopenshell.file params are passed as a file path string + if hint is ifcopenshell.file: + return "file_path" + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Union (including Optional) + if origin is typing.Union: + formatted = [_format_type_hint(a) for a in args] + # Optional[X] is Union[X, None] — render as "Optional[X]" + if len(formatted) == 2 and "None" in formatted: + inner = next(f for f in formatted if f != "None") + return f"Optional[{inner}]" + return " | ".join(formatted) + + # Literal + if origin is typing.Literal: + values = ", ".join(repr(a) for a in args) + return f"Literal[{values}]" + + # Generic types (list, dict, etc.) + if origin is not None: + origin_name = getattr(origin, "__name__", str(origin)) + if args: + inner = ", ".join(_format_type_hint(a) for a in args) + return f"{origin_name}[{inner}]" + return origin_name + + # Simple types + return getattr(hint, "__name__", str(hint)) + + +def _serialize_default(value): + """Serialize a default value to something JSON-friendly.""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + return repr(value) + + +def _parse_docstring_body(docstring: str) -> tuple[str, str]: + """Parse the summary and long description from a docstring.""" + lines = docstring.strip().split("\n") + summary = lines[0].strip() if lines else "" + body_lines = [] + in_body = False + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith(":param") or stripped.startswith(":return"): + break + if stripped.startswith("Example"): + break + if not in_body and not stripped: + in_body = True + continue + if in_body: + body_lines.append(stripped) + + long_description = " ".join(body_lines).strip() + # collapse multiple spaces + long_description = re.sub(r"\s+", " ", long_description) + return summary, long_description + + +_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b") + + +def _parse_param_docs(docstring: str) -> dict[str, str]: + """Extract :param name: description lines from a docstring.""" + params = {} + current_param = None + current_lines = [] + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":param\s+(\w+):\s*(.*)", stripped) + if match: + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = match.group(1) + current_lines = [match.group(2)] + elif current_param and stripped and not _FIELD_MARKER.match(stripped): + current_lines.append(stripped) + elif _FIELD_MARKER.match(stripped) or (stripped == "" and current_param): + if current_param: + params[current_param] = " ".join(current_lines).strip() + current_param = None + current_lines = [] + if current_param: + params[current_param] = " ".join(current_lines).strip() + # collapse whitespace + return {k: re.sub(r"\s+", " ", v) for k, v in params.items()} + + +def _parse_return_doc(docstring: str) -> str: + """Extract :return: description from a docstring.""" + lines = [] + in_return = False + for line in docstring.split("\n"): + stripped = line.strip() + match = re.match(r":return:\s*(.*)", stripped) + if match: + in_return = True + lines = [match.group(1)] + elif in_return: + if _FIELD_MARKER.match(stripped) or stripped == "": + break + lines.append(stripped) + return re.sub(r"\s+", " ", " ".join(lines).strip()) diff --git a/src/ifcedit/ifcedit/foreach.py b/src/ifcedit/ifcedit/foreach.py new file mode 100644 index 0000000000..587d611c3f --- /dev/null +++ b/src/ifcedit/ifcedit/foreach.py @@ -0,0 +1,76 @@ +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +from __future__ import annotations + +import ifcopenshell + +from ifcedit.run import run_api + + +def _substitute(template: str, item: dict) -> str: + """Replace {key} placeholders in template with values from item.""" + for key, value in item.items(): + template = template.replace(f"{{{key}}}", str(value)) + return template + + +def run_foreach( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs_template: dict[str, str], + items: list[dict], +) -> dict: + """Apply an API function to each item in a list, substituting {field} placeholders. + + Opens the model once, applies the mutation for every item, and returns a summary. + The caller is responsible for saving the model. + + Args: + model: The open IFC model (mutated in place). + module: API module name (e.g. "root"). + function: Function name (e.g. "remove_product"). + raw_kwargs_template: Arg templates with {field} placeholders, e.g. {"product": "{id}"}. + items: List of dicts (e.g. from ifcquery select output). + + Returns: + {"ok": True, "count": N, "errors": []} on full success, + {"ok": False, "count": N, "errors": [{...}]} if any item failed. + """ + errors = [] + count = 0 + + for i, item in enumerate(items): + if not isinstance(item, dict): + errors.append({"index": i, "item": item, "error": "item is not a dict"}) + continue + + substituted = {k: _substitute(v, item) for k, v in raw_kwargs_template.items()} + result = run_api(model, module, function, substituted) + + if result["ok"]: + count += 1 + else: + errors.append({"index": i, "item": item, "error": result["error"]}) + + return { + "ok": len(errors) == 0, + "count": count, + "errors": errors, + } diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py new file mode 100644 index 0000000000..f85475e22c --- /dev/null +++ b/src/ifcedit/ifcedit/quantify.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell + +AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"] + + +def list_rules() -> list[dict[str, str]]: + """Return a list of available quantification rule names.""" + return [{"name": name} for name in AVAILABLE_RULES] + + +def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place by adding/updating IfcElementQuantity psets. + Returns a summary dict with ok, rule, and elements_quantified. + """ + from ifc5d.qto import edit_qtos, quantify + from ifc5d.qto import rules as rule_sets + + if rule not in rule_sets: + return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"} + + import ifcopenshell.util.selector + + if selector: + elements = set(ifcopenshell.util.selector.filter_elements(model, selector)) + else: + elements = set(model.by_type("IfcElement")) + + results = quantify(model, elements, rule_sets[rule]) + edit_qtos(model, results) + return {"ok": True, "rule": rule, "elements_quantified": len(results)} diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py new file mode 100644 index 0000000000..91d8be6774 --- /dev/null +++ b/src/ifcedit/ifcedit/run.py @@ -0,0 +1,148 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcEdit. +# +# IfcEdit is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcEdit is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcEdit. If not, see . + +from __future__ import annotations + +import importlib +import inspect +import typing + +import ifcopenshell + +from ifcedit.coerce import coerce_value + + +def _is_file_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file]).""" + if hint is ifcopenshell.file: + return True + origin = typing.get_origin(hint) + args = typing.get_args(hint) + if origin is typing.Union and ifcopenshell.file in args: + return True + return False + + +def run_api( + model: ifcopenshell.file, + module: str, + function: str, + raw_kwargs: dict[str, str], +) -> dict: + """Execute an ifcopenshell.api function with CLI-provided string arguments. + + Args: + model: The open IFC model. + module: API module name (e.g. "root"). + function: Function name (e.g. "create_entity"). + raw_kwargs: String keyword arguments from the CLI. + + Returns: + A dict with {"ok": True, "result": ...} on success, + or {"ok": False, "error": "..."} on failure. + """ + try: + fn = _import_function(module, function) + except (ImportError, AttributeError) as e: + return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"} + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + sig = inspect.signature(fn) + coerced_kwargs = {} + + # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset). + # The opened file is then used as the lookup file for entity resolution in pass 2. + opened_files: list[ifcopenshell.file] = [] + for name, value_str in raw_kwargs.items(): + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + if not _is_file_type(hint): + continue + try: + coerced = coerce_value(value_str, hint, model) + coerced_kwargs[name] = coerced + if isinstance(coerced, ifcopenshell.file): + opened_files.append(coerced) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened + # library file (if any), since you are always appending from another file, never + # from the current model. + lookup_file = opened_files[0] if opened_files else None + for name, value_str in raw_kwargs.items(): + if name in coerced_kwargs: + continue + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + try: + coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Determine if the function takes 'file' as its first parameter + first_param = next(iter(sig.parameters), None) + try: + if first_param == "file": + result = fn(model, **coerced_kwargs) + else: + result = fn(**coerced_kwargs) + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + return {"ok": True, "result": serialize_result(result)} + + +def _import_function(module: str, function: str): + """Import and return the underlying function from ifcopenshell.api.""" + fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}") + fn = getattr(fn_module, function) + return fn + + +def serialize_result(value) -> object: + """Serialize an API result to a JSON-friendly structure.""" + if value is None: + return None + if isinstance(value, ifcopenshell.entity_instance): + return _serialize_entity(value) + if isinstance(value, (list, tuple, set, frozenset)): + return [serialize_result(item) for item in value] + if isinstance(value, dict): + return {str(k): serialize_result(v) for k, v in value.items()} + if isinstance(value, (str, int, float, bool)): + return value + return str(value) + + +def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict: + """Serialize an entity instance to a summary dict.""" + result = { + "id": entity.id(), + "type": entity.is_a(), + } + if hasattr(entity, "Name") and entity.Name: + result["name"] = entity.Name + return result diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml new file mode 100644 index 0000000000..f50c0b3cc4 --- /dev/null +++ b/src/ifcedit/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcedit" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI wrapper for ifcopenshell.api IFC model mutation functions" +readme = "README.md" +keywords = ["IFC", "BIM", "API"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifc5d"] + +[project.scripts] +ifcedit = "ifcedit.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcedit*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcedit/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py new file mode 100644 index 0000000000..241220b145 --- /dev/null +++ b/src/ifcedit/tests/conftest.py @@ -0,0 +1,63 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.material +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.pset +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + + +@pytest.fixture +def model(): + """Create an IFC4 model with a spatial hierarchy and a wall.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def library(): + """Create an IFC4 library with a single IfcWallType asset.""" + lib = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary") + ifcopenshell.api.unit.assign_unit(lib) + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01") + return lib + + +@pytest.fixture +def library_file(library, tmp_path): + """Write the library fixture to a temp file and return the path.""" + path = tmp_path / "library.ifc" + library.write(str(path)) + return str(path) diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py new file mode 100644 index 0000000000..4ee6895717 --- /dev/null +++ b/src/ifcedit/tests/test_coerce.py @@ -0,0 +1,158 @@ +# This file was generated with the assistance of an AI coding tool. +import json +from typing import Literal, Optional, Union + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcedit.coerce import coerce_value + + +class TestStringCoercion: + def test_plain_string(self): + assert coerce_value("hello", str) == "hello" + + def test_empty_string(self): + assert coerce_value("", str) == "" + + +class TestIntCoercion: + def test_plain_int(self): + assert coerce_value("42", int) == 42 + + def test_hash_prefix(self): + assert coerce_value("#42", int) == 42 + + def test_negative(self): + assert coerce_value("-5", int) == -5 + + +class TestFloatCoercion: + def test_plain_float(self): + assert coerce_value("3.14", float) == pytest.approx(3.14) + + def test_integer_as_float(self): + assert coerce_value("5", float) == 5.0 + + +class TestBoolCoercion: + def test_true_values(self): + for val in ("true", "True", "TRUE", "1", "yes"): + assert coerce_value(val, bool) is True + + def test_false_values(self): + for val in ("false", "False", "0", "no"): + assert coerce_value(val, bool) is False + + +class TestOptionalCoercion: + def test_optional_string(self): + assert coerce_value("hello", Optional[str]) == "hello" + + def test_optional_none(self): + assert coerce_value("none", Optional[str]) is None + assert coerce_value("None", Optional[str]) is None + + def test_optional_int(self): + assert coerce_value("42", Optional[int]) == 42 + + +class TestUnionCoercion: + def test_union_str_int(self): + # Tries str first (or int first depending on order), both work + result = coerce_value("hello", Union[str, int]) + assert result == "hello" + + def test_union_int_none(self): + result = coerce_value("42", Union[int, None]) + assert result == 42 + + +class TestLiteralCoercion: + def test_valid_literal(self): + assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4" + + def test_invalid_literal(self): + with pytest.raises(ValueError, match="not one of"): + coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"]) + + +class TestDictCoercion: + def test_json_dict(self): + result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object]) + assert result == {"IsExternal": True, "FireRating": "2HR"} + + def test_mixed_float_int_list_coerced_to_float(self): + # [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float + # so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list + result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object]) + assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908]) + assert all(isinstance(v, float) for v in result["DirectionRatios"]) + + def test_pure_int_list_not_coerced(self): + # All-integer lists (e.g. face indices) must stay as ints + result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object]) + assert result["CoordIndex"] == [0, 1, 2] + assert all(isinstance(v, int) for v in result["CoordIndex"]) + + +class TestListCoercion: + def test_comma_separated(self): + result = coerce_value("a,b,c", list[str]) + assert result == ["a", "b", "c"] + + def test_json_array(self): + result = coerce_value("[1, 2, 3]", list[int]) + assert result == [1, 2, 3] + + +class TestEntityCoercion: + def test_entity_by_id(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_with_hash(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model) + assert result == wall + + def test_entity_not_found(self, model): + with pytest.raises(ValueError, match="not found"): + coerce_value("999999", ifcopenshell.entity_instance, model) + + def test_entity_list(self, model): + wall = model.by_type("IfcWall")[0] + result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model) + assert len(result) == 1 + assert result[0] == wall + + def test_entity_list_multiple(self, model): + wall = model.by_type("IfcWall")[0] + storey = model.by_type("IfcBuildingStorey")[0] + result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model) + assert len(result) == 2 + + def test_entity_no_model(self): + with pytest.raises(ValueError, match="without an IFC model"): + coerce_value("42", ifcopenshell.entity_instance, None) + + +class TestFileCoercion: + def test_opens_file_from_path(self, model_file): + result = coerce_value(model_file, ifcopenshell.file) + assert isinstance(result, ifcopenshell.file) + + def test_entity_from_lookup_file(self, model_file): + lib = ifcopenshell.open(model_file) + wall = lib.by_type("IfcWall")[0] + empty_model = ifcopenshell.api.project.create_file() + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib) + assert result.id() == wall.id() + assert result.is_a("IfcWall") + + +class TestFallback: + def test_no_type_hint(self): + assert coerce_value("hello", None) == "hello" diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py new file mode 100644 index 0000000000..7b77cb5b7e --- /dev/null +++ b/src/ifcedit/tests/test_discover.py @@ -0,0 +1,104 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcedit.discover import function_docs, list_functions, list_modules + + +class TestListModules: + def test_returns_list(self): + result = list_modules() + assert isinstance(result, list) + assert len(result) > 0 + + def test_module_structure(self): + result = list_modules() + for entry in result: + assert "module" in entry + assert "description" in entry + assert "functions" in entry + assert "count" in entry + assert isinstance(entry["functions"], list) + assert entry["count"] == len(entry["functions"]) + + def test_known_modules_present(self): + result = list_modules() + module_names = [m["module"] for m in result] + for expected in ("root", "spatial", "pset", "aggregate", "unit"): + assert expected in module_names + + def test_root_module_has_functions(self): + result = list_modules() + root = next(m for m in result if m["module"] == "root") + assert "create_entity" in root["functions"] + assert root["count"] >= 3 + + +class TestListFunctions: + def test_root_functions(self): + result = list_functions("root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_function_structure(self): + result = list_functions("root") + for fn in result: + assert "name" in fn + assert "description" in fn + assert "params" in fn + + def test_create_entity_params(self): + result = list_functions("root") + create = next(f for f in result if f["name"] == "create_entity") + param_names = [p["name"] for p in create["params"]] + assert "ifc_class" in param_names + assert "name" in param_names + + def test_pset_functions(self): + result = list_functions("pset") + names = [f["name"] for f in result] + assert "add_pset" in names + assert "edit_pset" in names + + +class TestFunctionDocs: + def test_create_entity_docs(self): + result = function_docs("root", "create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert result["description"] + assert isinstance(result["params"], list) + assert len(result["params"]) > 0 + + def test_params_have_types(self): + result = function_docs("root", "create_entity") + for param in result["params"]: + assert "name" in param + assert "type" in param + + def test_params_have_descriptions(self): + result = function_docs("root", "create_entity") + ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class") + assert "description" in ifc_class + assert len(ifc_class["description"]) > 0 + + def test_return_type(self): + result = function_docs("root", "create_entity") + assert "return_type" in result + + def test_assign_container_docs(self): + result = function_docs("spatial", "assign_container") + assert result["module"] == "spatial" + param_names = [p["name"] for p in result["params"]] + assert "products" in param_names + assert "relating_structure" in param_names + + def test_unknown_function_raises(self): + import pytest + + with pytest.raises(ValueError, match="not found"): + function_docs("root", "nonexistent_function") + + def test_edit_pset_docs(self): + result = function_docs("pset", "edit_pset") + param_names = [p["name"] for p in result["params"]] + assert "pset" in param_names + assert "properties" in param_names diff --git a/src/ifcedit/tests/test_foreach.py b/src/ifcedit/tests/test_foreach.py new file mode 100644 index 0000000000..d464fedcf3 --- /dev/null +++ b/src/ifcedit/tests/test_foreach.py @@ -0,0 +1,85 @@ +# Tests for ifcedit.foreach +import ifcopenshell +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.foreach import _substitute, run_foreach + + +@pytest.fixture +def model(model): + return model + + +class TestSubstitute: + def test_single_field(self): + assert _substitute("--product {id}", {"id": 42}) == "--product 42" + + def test_multiple_fields(self): + result = _substitute("{type} #{id} ({name})", {"id": 5, "type": "IfcWall", "name": "W1"}) + assert result == "IfcWall #5 (W1)" + + def test_no_placeholder(self): + assert _substitute("hello", {"id": 1}) == "hello" + + def test_unknown_placeholder_unchanged(self): + assert _substitute("{unknown}", {"id": 1}) == "{unknown}" + + +class TestRunForeach: + def _items(self, model, ifc_class): + return [{"id": e.id(), "type": e.is_a(), "name": e.Name} for e in model.by_type(ifc_class)] + + def test_rename_single(self, model): + items = self._items(model, "IfcWall") + result = run_foreach( + model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "R"}'}, items + ) + assert result["ok"] is True + assert result["count"] == 1 + assert result["errors"] == [] + assert model.by_type("IfcWall")[0].Name == "R" + + def test_rename_multiple(self, model): + items = self._items(model, "IfcElement") + result = run_foreach( + model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "X"}'}, items + ) + assert result["ok"] is True + assert result["count"] == len(items) + + def test_empty_list(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, []) + assert result["ok"] is True + assert result["count"] == 0 + assert result["errors"] == [] + + def test_bad_id_collects_error(self, model): + items = [{"id": 999999, "type": "IfcWall", "name": "X"}] + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, items) + assert result["ok"] is False + assert result["count"] == 0 + assert len(result["errors"]) == 1 + assert result["errors"][0]["index"] == 0 + + def test_non_dict_item_collects_error(self, model): + result = run_foreach(model, "root", "remove_product", {"product": "{id}"}, ["not_a_dict"]) + assert result["ok"] is False + assert len(result["errors"]) == 1 + + def test_partial_failure_counts_successes(self, model): + wall_id = model.by_type("IfcWall")[0].id() + items = [ + {"id": wall_id, "type": "IfcWall", "name": "W"}, + {"id": 999999, "type": "IfcWall", "name": "Bad"}, + ] + result = run_foreach( + model, "attribute", "edit_attributes", {"product": "{id}", "attributes": '{"Name": "Ok"}'}, items + ) + assert result["ok"] is False + assert result["count"] == 1 + assert len(result["errors"]) == 1 diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py new file mode 100644 index 0000000000..00585d5172 --- /dev/null +++ b/src/ifcedit/tests/test_main.py @@ -0,0 +1,211 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import subprocess +import sys + +import ifcopenshell +import pytest + + +def run_ifcedit(*args, stdin=None): + """Run ifcedit as a subprocess and return (stdout, stderr, returncode).""" + result = subprocess.run( + [sys.executable, "-m", "ifcedit", *args], + capture_output=True, + text=True, + input=stdin, + ) + return result.stdout, result.stderr, result.returncode + + +class TestListCommand: + def test_list_all_modules(self): + stdout, stderr, rc = run_ifcedit("list") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + module_names = [m["module"] for m in data] + assert "root" in module_names + assert "spatial" in module_names + + def test_list_module_functions(self): + stdout, stderr, rc = run_ifcedit("list", "root") + assert rc == 0 + data = json.loads(stdout) + assert isinstance(data, list) + names = [f["name"] for f in data] + assert "create_entity" in names + + def test_list_text_format(self): + stdout, stderr, rc = run_ifcedit("--format", "text", "list") + assert rc == 0 + assert "root" in stdout + + +class TestDocsCommand: + def test_docs_create_entity(self): + stdout, stderr, rc = run_ifcedit("docs", "root.create_entity") + assert rc == 0 + data = json.loads(stdout) + assert data["module"] == "root" + assert data["function"] == "create_entity" + assert "params" in data + + def test_docs_invalid_path(self): + stdout, stderr, rc = run_ifcedit("docs", "invalid_path") + assert rc != 0 + assert "module.function" in stderr + + def test_docs_unknown_function(self): + stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent") + assert rc != 0 + + +class TestRunCommand: + def test_create_entity(self, model_file): + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["result"]["type"] == "IfcWall" + assert data["result"]["name"] == "CLIWall" + + def test_dry_run(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["dry_run"] is True + + def test_output_to_different_file(self, model_file, tmp_path): + output = str(tmp_path / "output.ifc") + stdout, stderr, rc = run_ifcedit( + "run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab" + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + + import os + + assert os.path.exists(output) + + def test_run_error_bad_function(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent") + assert rc != 0 + + def test_run_invalid_function_path(self, model_file): + stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path") + assert rc != 0 + assert "module.function" in stderr + + +class TestForeachCommand: + def _select_json(self, model, ifc_class): + """Build a JSON array like ifcquery select would produce.""" + elements = model.by_type(ifc_class) + return json.dumps([{"id": e.id(), "type": e.is_a(), "name": getattr(e, "Name", None)} for e in elements]) + + def test_foreach_rename(self, model, model_file): + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "attribute.edit_attributes", + "--product", + "{id}", + "--attributes", + '{"Name": "Renamed"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 1 + assert data["errors"] == [] + updated = ifcopenshell.open(model_file) + assert updated.by_type("IfcWall")[0].Name == "Renamed" + + def test_foreach_multiple_elements(self, model, model_file): + # Build a two-item list by selecting all IfcObject (includes spatial structure + elements) + elements_json = self._select_json(model, "IfcObject") + items = json.loads(elements_json) + assert len(items) >= 2 + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "attribute.edit_attributes", + "--product", + "{id}", + "--attributes", + '{"Name": "Bulk"}', + stdin=elements_json, + ) + assert rc == 0, f"stderr: {stderr}" + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == len(items) + + def test_foreach_empty_list(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "attribute.edit_attributes", + "--product", + "{id}", + "--attributes", + '{"Name": "X"}', + stdin="[]", + ) + assert rc == 0 + data = json.loads(stdout) + assert data["ok"] is True + assert data["count"] == 0 + + def test_foreach_invalid_json_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "root.remove_product", + "--product", + "{id}", + stdin="not json", + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_not_array_stdin(self, model_file): + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "root.remove_product", + "--product", + "{id}", + stdin='{"id": 1}', + ) + assert rc != 0 + assert "Error" in stderr + + def test_foreach_output_to_different_file(self, model, model_file, tmp_path): + import os + + output = str(tmp_path / "out.ifc") + walls_json = self._select_json(model, "IfcWall") + stdout, stderr, rc = run_ifcedit( + "foreach", + model_file, + "attribute.edit_attributes", + "-o", + output, + "--product", + "{id}", + "--attributes", + '{"Name": "OutFile"}', + stdin=walls_json, + ) + assert rc == 0, f"stderr: {stderr}" + assert os.path.exists(output) + updated = ifcopenshell.open(output) + assert updated.by_type("IfcWall")[0].Name == "OutFile" diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py new file mode 100644 index 0000000000..f4335fb2a7 --- /dev/null +++ b/src/ifcedit/tests/test_quantify.py @@ -0,0 +1,87 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify + + +class TestListRules: + def test_returns_list(self): + result = list_rules() + assert isinstance(result, list) + + def test_each_entry_has_name(self): + result = list_rules() + for entry in result: + assert "name" in entry + + def test_ifc4_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4QtoBaseQuantities" in names + + def test_ifc4x3_rule_present(self): + result = list_rules() + names = [r["name"] for r in result] + assert "IFC4X3QtoBaseQuantities" in names + + +@pytest.fixture +def quantify_model(): + """Create an IFC4 model with a wall element.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + return f + + +class TestRunQuantify: + def test_returns_ok_true(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["ok"] is True + + def test_returns_rule_name(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_returns_elements_quantified(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities") + assert "elements_quantified" in result + assert isinstance(result["elements_quantified"], int) + + def test_unknown_rule_returns_error(self, quantify_model): + result = run_quantify(quantify_model, "NonExistentRule") + assert result["ok"] is False + assert "error" in result + + def test_selector_restricts_elements(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall") + assert result["ok"] is True + assert result["rule"] == "IFC4QtoBaseQuantities" + + def test_empty_selector_runs_on_all(self, quantify_model): + result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None) + assert result["ok"] is True diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py new file mode 100644 index 0000000000..80881e879f --- /dev/null +++ b/src/ifcedit/tests/test_run.py @@ -0,0 +1,97 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project +import ifcopenshell.api.pset +import ifcopenshell.api.root + +from ifcedit.run import run_api, serialize_result + + +class TestRunApi: + def test_create_entity(self, model): + result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + assert isinstance(result["result"]["id"], int) + + def test_create_entity_default_class(self, model): + result = run_api(model, "root", "create_entity", {}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcBuildingElementProxy" + + def test_assign_container(self, model): + wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2") + storey = model.by_type("IfcBuildingStorey")[0] + result = run_api( + model, + "spatial", + "assign_container", + {"products": str(wall.id()), "relating_structure": str(storey.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcRelContainedInSpatialStructure" + + def test_add_pset(self, model): + wall = model.by_type("IfcWall")[0] + result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"}) + assert result["ok"] is True + assert result["result"]["type"] == "IfcPropertySet" + + def test_unknown_function(self, model): + result = run_api(model, "root", "nonexistent", {}) + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, model): + result = run_api(model, "root", "create_entity", {"bogus_param": "value"}) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_entity_reference(self, model): + result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"}) + assert result["ok"] is False + assert "not found" in result["error"] + + +class TestAppendAsset: + def test_append_asset_from_library(self, model, library_file): + lib = ifcopenshell.open(library_file) + wall_type = lib.by_type("IfcWallType")[0] + result = run_api( + model, + "project", + "append_asset", + {"library": library_file, "element": str(wall_type.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWallType" + assert model.by_type("IfcWallType"), "wall type should have been appended to the model" + + +class TestSerializeResult: + def test_none(self): + assert serialize_result(None) is None + + def test_string(self): + assert serialize_result("hello") == "hello" + + def test_int(self): + assert serialize_result(42) == 42 + + def test_entity(self, model): + wall = model.by_type("IfcWall")[0] + result = serialize_result(wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["name"] == "Wall001" + + def test_list(self, model): + walls = model.by_type("IfcWall") + result = serialize_result(walls) + assert isinstance(result, list) + assert all(isinstance(r, dict) for r in result) + + def test_dict(self): + result = serialize_result({"key": "value"}) + assert result == {"key": "value"} diff --git a/src/ifcfm/ifcfm/cobie24.py b/src/ifcfm/ifcfm/cobie24.py index f4848e1654..b83a4abe08 100644 --- a/src/ifcfm/ifcfm/cobie24.py +++ b/src/ifcfm/ifcfm/cobie24.py @@ -955,7 +955,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str return val(unit.Currency) -def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]: +def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]: if unit.is_a("IfcNamedUnit"): return val(unit.Name) diff --git a/src/ifcfm/ifcfm/cobie24legacy.py b/src/ifcfm/ifcfm/cobie24legacy.py index 078529e892..b3aa069e75 100644 --- a/src/ifcfm/ifcfm/cobie24legacy.py +++ b/src/ifcfm/ifcfm/cobie24legacy.py @@ -953,7 +953,7 @@ def get_unit_type_name(ifc_file: ifcopenshell.file, unit_type: str) -> Union[str return val(unit.Currency) -def get_unit_name(ifc_file: ifcopenshell.entity_instance, unit: ifcopenshell.entity_instance) -> Union[str, None]: +def get_unit_name(unit: ifcopenshell.entity_instance) -> Union[str, None]: if unit.is_a("IfcNamedUnit"): return val(unit.Name) diff --git a/src/ifcgeom/function_item_evaluator.cpp b/src/ifcgeom/function_item_evaluator.cpp index 9bacf94ac0..9458a04fea 100644 --- a/src/ifcgeom/function_item_evaluator.cpp +++ b/src/ifcgeom/function_item_evaluator.cpp @@ -104,14 +104,6 @@ struct gradient_fn_evaluator : public fn_evaluator { auto xy = horizontal_evaluator_.evaluate(u); auto uz = vertical_evaluator_.evaluate(u); - // curvature is stored in row 3 - capture it and remove it from the xy and uz matrices - // so the matrix operations (ie multiplication) works correct.y - auto horizontal_curvature = xy.row(3); - xy.row(3) = Eigen::Vector4d(0, 0, 0, 1); - - auto vertical_curvature = uz.row(3); - uz.row(3) = Eigen::Vector4d(0, 0, 0, 1); - uz(0, 3) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z uz.row(1).swap(uz.row(2)); @@ -119,12 +111,6 @@ struct gradient_fn_evaluator : public fn_evaluator { Eigen::Matrix4d m; m = xy * uz; // combine horizontal and vertical - // Put curvature back into the solution matrix - // curvature for vertical is in column 0, need it to be in column 1 - // so it doesn't add to curvature for horizontal - std::swap(vertical_curvature(0), vertical_curvature(1)); - m.row(3) = horizontal_curvature + vertical_curvature; - return m; } diff --git a/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp b/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp index f86f59ac98..b11b61b46b 100644 --- a/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp +++ b/src/ifcgeom/mapping/IfcAxis2PlacementLinear.cpp @@ -29,8 +29,10 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAxis2PlacementLinear* inst) Logger::Error(std::runtime_error("Location must be IfcPointByDistanceExpression for IfcAxis2PlacementLinear")); } + Eigen::Vector3d o, axis(0, 0, 1), refDirection; + taxonomy::matrix4::ptr m = taxonomy::cast(map(inst->Location())); - Eigen::Vector3d o = m->components().col(3).head<3>(); + o = m->components().col(3).head<3>(); // From 8.9.3.4 IfcAxis2PlacementLinear there are 4 cases that need to be considered // 1) Axis is given but not RefDirection @@ -38,12 +40,43 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcAxis2PlacementLinear* inst) // 3) Neither Axis or RefDirection are provided // 4) Both Axis and RefDirection are provided - Eigen::Vector3d z = inst->Axis() ? *taxonomy::cast(map(inst->Axis()))->components_ : Eigen::Vector3d(0,0,1); // Axis is (0,0,1) when omitted - Eigen::Vector3d rd = inst->RefDirection() ? *taxonomy::cast(map(inst->RefDirection()))->components_ : m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted - Eigen::Vector3d y = z.cross(rd); - Eigen::Vector3d x = y.cross(z); + const bool hasAxis = inst->Axis() != nullptr; + const bool hasRef = inst->RefDirection() != nullptr; - return taxonomy::make(o, z, x); + /* + if (hasAxis != hasRef) { + Logger::Warning("Axis and RefDirection should be specified together", inst); + } + */ + + if (hasAxis && !hasRef) { + taxonomy::direction3::ptr a = taxonomy::cast(map(inst->Axis())); + axis = *a->components_; + + refDirection = m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted + // refDirection is not necessarily orthogonal to axis. + // axis.cross(refDirection) gives y. y.cross(axis) gives x=refDirection + refDirection = axis.cross(refDirection).cross(axis); + } else if (!hasAxis && hasRef) { + taxonomy::direction3::ptr r = taxonomy::cast(map(inst->RefDirection())); + refDirection = *r->components_; + Eigen::Vector3d up(0, 0, 1); + axis = refDirection.cross(up.cross(refDirection)); + } else if (!hasAxis && !hasRef) { + refDirection = m->components().col(0).head<3>(); // RefDirection is the curve tangent when omitted + Eigen::Vector3d up(0, 0, 1); + axis = refDirection.cross(up.cross(refDirection)); + } else { + taxonomy::direction3::ptr a = taxonomy::cast(map(inst->Axis())); + axis = *a->components_; + + taxonomy::direction3::ptr r = taxonomy::cast(map(inst->RefDirection())); + refDirection = *r->components_; + refDirection = axis.cross(refDirection).cross(axis); // refDirection needs to be orthogonal to axis + } + + // axis and refDirection need to be orthogonal + return taxonomy::make(o, axis, refDirection); } #endif diff --git a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp index 180226fb82..f07234a8f1 100644 --- a/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp +++ b/src/ifcgeom/mapping/IfcPointByDistanceExpression.cpp @@ -52,6 +52,15 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i if (inst->OffsetVertical().has_value()) { auto offset_vertical = inst->OffsetVertical().get() * length_unit_; o += offset_vertical * z; + + auto tmp1 = (z * offset_vertical).eval(); + auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval(); + auto tmp3 = (tmp1 - tmp2).eval(); + + std::ostringstream oss; + oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z(); + auto osss = oss.str(); + std::wcout << osss.c_str() << std::endl; } if (inst->OffsetLongitudinal().has_value()) { diff --git a/src/ifcgeom/mapping/mapping.cpp b/src/ifcgeom/mapping/mapping.cpp index edfdd3a13b..a44ef1a57a 100644 --- a/src/ifcgeom/mapping/mapping.cpp +++ b/src/ifcgeom/mapping/mapping.cpp @@ -562,6 +562,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial* material) { } // Check if it's failed or just some unsupported case. if (failed_on_purpose_.find(styled_item) == failed_on_purpose_.end()) { + failed_on_purpose_.insert(material); return nullptr; } Logger::Warning("Skipping unsupported material style for material: ", material); @@ -569,6 +570,7 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial* material) { } // When material does not have a representation we don't create a style from it + failed_on_purpose_.insert(material); return nullptr; /* diff --git a/src/ifcmcp/Makefile b/src/ifcmcp/Makefile new file mode 100644 index 0000000000..3dbcbb4e93 --- /dev/null +++ b/src/ifcmcp/Makefile @@ -0,0 +1,10 @@ +PACKAGE_NAME:=ifcmcp +include ../common.mk + +.PHONY: test +test: + pytest tests + +.PHONY: qa +qa: + black . diff --git a/src/ifcmcp/README.md b/src/ifcmcp/README.md new file mode 100644 index 0000000000..6d513bfd07 --- /dev/null +++ b/src/ifcmcp/README.md @@ -0,0 +1,400 @@ + +# ifcmcp + +An MCP (Model Context Protocol) server that wraps `ifcquery` and `ifcedit`, +holding the IFC model in memory across tool calls for fast interactive editing +sessions. + +## Installation + +```bash +pip install ifcopenshell-mcp +``` + +Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcopenshell-mcp[mcp]` or add `mcp` separately. + +## Running the server + +```bash +ifcmcp +``` + +This starts the server on stdio transport, suitable for use with Claude Code +or any MCP client. + +### Claude Code configuration + +Use the `claude mcp add` command: + +```bash +claude mcp add --transport stdio ifc -- ifcmcp +``` + +Or create a `.mcp.json` file in your project root: + +```json +{ + "mcpServers": { + "ifc": { + "type": "stdio", + "command": "ifcmcp" + } + } +} +``` + +After adding the server, restart Claude Code for the tools to become available. +Then load a model by asking Claude to use `ifc_load`: + +``` +load model.ifc using ifc_load +``` + +## Tools + +### Session + +#### ifc_new + +Create a new empty IFC model in memory, replacing any currently loaded model. + +``` +ifc_new() +ifc_new(schema="IFC4X3") +``` + +Default schema is `IFC4`. + +#### ifc_load + +Open an IFC file into memory. + +``` +ifc_load(path="/path/to/model.ifc") +-> "Loaded /path/to/model.ifc: schema IFC4, 1847 entities" +``` + +#### ifc_reset + +Unload the current model from memory, freeing all session state. + +``` +ifc_reset() +``` + +#### ifc_save + +Write the in-memory model to disk. Empty path overwrites the original file. + +``` +ifc_save() +ifc_save(path="/path/to/output.ifc") +``` + +### Query tools + +All query tools require a model to be loaded first via `ifc_load`. + +#### ifc_summary + +Model overview: schema, entity counts, project info. + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": {"id": 1, "name": "Office Building"}, + "types": {"IfcWall": 42, "IfcSlab": 12, "IfcWindow": 36} +} +``` + +#### ifc_tree + +Full spatial hierarchy from IfcProject down through sites, buildings, storeys, +and contained elements. + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "children": [{"id": 3, "type": "IfcBuilding", "children": ["..."]}] + } + ] +} +``` + +#### ifc_info + +Deep inspection of an entity by step ID: attributes, property sets, type, +material, container, and 4x4 placement matrix. + +``` +ifc_info(element_id=10) +``` + +#### ifc_select + +Filter elements using ifcopenshell selector syntax. + +``` +ifc_select(query="IfcWall") +ifc_select(query="IfcWindow") +``` + +Returns a sorted list of `{"id", "type", "name"}` references. + +#### ifc_relations + +Show all relationships for an element: hierarchy, children, type, groups, +systems, material, connections. + +``` +ifc_relations(element_id=10) +ifc_relations(element_id=10, traverse="up") +``` + +With `traverse="up"`, walks the hierarchy from element up to IfcProject. + +#### ifc_contexts + +List all geometric representation contexts and subcontexts in the loaded model. + +``` +ifc_contexts() +``` + +#### ifc_materials + +List all materials and material sets in the loaded model, with their assigned elements. + +``` +ifc_materials() +``` + +#### ifc_clash + +Check an element for geometric intersections and clearance violations. + +``` +ifc_clash(element_id=10) +ifc_clash(element_id=10, clearance=0.5, scope="all") +``` + +Parameters: + +- `clearance` -- minimum clearance distance in meters (0.0 = no clearance check) +- `tolerance` -- intersection tolerance in meters (default: 0.002) +- `scope` -- `"storey"` or `"all"` (default: `"storey"`) + +#### ifc_validate + +Check the model for schema and constraint violations. + +``` +ifc_validate() +ifc_validate(express_rules=True) +``` + +Returns `{"valid": true, "issues": []}` or `{"valid": false, "issues": [{"level": "ERROR", "message": "..."}]}`. + +#### ifc_schedule + +List all work schedules and their nested task trees. + +``` +ifc_schedule() +ifc_schedule(max_depth=1) # top-level phases only +``` + +`max_depth` limits subtask expansion. At the cutoff, `subtasks` is replaced +with `{"truncated": true, "count": N}` so you know children exist without +fetching them all. Omit for unlimited depth. + +#### ifc_cost + +List all cost schedules and their nested cost item trees. + +``` +ifc_cost() +ifc_cost(max_depth=2) # top two levels of the BoQ +``` + +`max_depth` limits cost item expansion, same truncation convention as +`ifc_schedule`. + +#### ifc_schema + +Return IFC class documentation for any entity type, using the loaded model's +schema version. + +``` +ifc_schema(entity_type="IfcWall") +ifc_schema(entity_type="IfcBuildingStorey") +``` + +Returns description, predefined types, spec URL, and attribute descriptions. +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +#### ifc_quantify + +Run quantity take-off (QTO) on the loaded model using an `ifc5d` rule. +Computes physical measurements (volume, area, length, count, weight) and +writes them back as `IfcElementQuantity` property sets. Modifies the model +in-place -- call `ifc_save()` when done. + +``` +ifc_quantify(rule="IFC4QtoBaseQuantities") +ifc_quantify(rule="IFC4QtoBaseQuantities", selector="IfcWall") +``` + +Available rules: `IFC4QtoBaseQuantities`, `IFC4X3QtoBaseQuantities`. + +`selector` is an optional ifcopenshell selector to restrict which elements +are quantified (default: all `IfcElement`). + +Returns `{"ok": true, "rule": "...", "elements_quantified": 42}`. + +### Drawing and rendering tools + +#### ifc_plot + +Generate a 2D technical drawing of the loaded model and return it as an inline image. + +``` +ifc_plot() +ifc_plot(selector="IfcWall", view="floorplan", scale=0.01, output_path="/tmp/plan.svg") +ifc_plot(element_ids=[10, 11], view="floorplan") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict plotted elements +- `element_ids` -- step IDs of elements to highlight; others are faded +- `view` -- `"floorplan"` (default), `"elevation"`, `"section"`, or `"auto"` +- `width_mm`, `height_mm` -- paper size in mm (default: 297 x 420) +- `scale` -- model-to-paper ratio (default: 0.01 = 1:100) +- `png_width`, `png_height` -- raster output size in pixels (default: 1024 x 1024) +- `output_path` -- optional path to also save to disk (`.svg` for vector, otherwise PNG) + +Returns an inline PNG the LLM can inspect. Requires `ifcopenshell.draw`. + +#### ifc_render + +Render the loaded model to a 3D PNG image. + +``` +ifc_render() +ifc_render(selector="IfcWall", view="iso", output_path="/tmp/model.png") +ifc_render(element_ids=[10, 11], view="south") +``` + +Parameters: + +- `selector` -- ifcopenshell selector to restrict rendered elements +- `element_ids` -- step IDs of elements to highlight; others are shown translucent +- `view` -- `"iso"` (default), `"top"`, `"south"`, `"north"`, `"east"`, or `"west"` +- `output_path` -- optional path to save the PNG to disk + +Returns an inline PNG. Requires `pyvista` and the IfcOpenShell C++ geometry bindings. + +### Shape builder tools + +#### ifc_shape_list + +List all available `ShapeBuilder` methods with brief descriptions. + +``` +ifc_shape_list() +``` + +#### ifc_shape_docs + +Show full documentation for a specific `ShapeBuilder` method. + +``` +ifc_shape_docs(method="extrude") +ifc_shape_docs(method="create_ellipse") +``` + +#### ifc_shape + +Execute a `ShapeBuilder` method on the loaded model. + +``` +ifc_shape(method="extrude", params='{"profile": "42", "magnitude": 3.0}') +``` + +`params` is a JSON string; entity references are resolved by step ID (same coercion as `ifc_edit`). + +### Edit discovery tools + +#### ifc_list + +List all API modules, or functions within a specific module. + +``` +ifc_list() # all modules +ifc_list(module="root") # functions in the root module +``` + +#### ifc_docs + +Show full documentation for an API function including parameters, types, +defaults, and descriptions. + +``` +ifc_docs(function_path="root.create_entity") +``` + +### Edit execution + +#### ifc_edit + +Execute an `ifcopenshell.api` mutation function. Parameters are passed as a +JSON string with string values that get coerced by ifcedit's type system. + +``` +ifc_edit( + function_path="root.create_entity", + params='{"ifc_class": "IfcWall", "name": "My Wall"}' +) +``` + +Returns `{"ok": true, "result": ...}` or `{"ok": false, "error": "..."}`. + +Does NOT auto-save -- call `ifc_save()` when ready to write changes to disk. + +**Parameter coercion:** + +| Type | JSON value | Python value | +|------|------------|--------------| +| `entity_instance` | `"42"` | resolved from model by step ID | +| `list[entity_instance]` | `"5,6,7"` | list of resolved entities | +| `dict` | `'{"key": "val"}'` | parsed JSON object | +| `bool` | `"true"` | `True` | +| `Optional[X]` | `"none"` | `None` | + +## Typical workflow + +1. **Load** a model: `ifc_load` +2. **Inspect** with query tools: `ifc_summary`, `ifc_tree`, `ifc_select`, `ifc_info`, `ifc_relations` +3. **Validate** if needed: `ifc_validate` +4. **Browse schedules / costs**: `ifc_schedule`, `ifc_cost` (use `max_depth=1` first on large projects) +5. **Look up IFC classes**: `ifc_schema` +6. **Find** the right API function: `ifc_list`, `ifc_docs` +7. **Edit** the model: `ifc_edit` +8. **Quantify** elements: `ifc_quantify` (writes QTO psets in-place) +9. **Verify** changes with query tools +10. **Save** when satisfied: `ifc_save` + +The model stays in memory across all calls, so multi-step editing sessions +are fast -- no file I/O between operations. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcmcp/ifcmcp/__init__.py b/src/ifcmcp/ifcmcp/__init__.py new file mode 100644 index 0000000000..92e1c230b6 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcMCP - MCP server for IFC building models +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcMCP. +# +# IfcMCP is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcMCP is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcMCP. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcmcp/ifcmcp/__main__.py b/src/ifcmcp/ifcmcp/__main__.py new file mode 100644 index 0000000000..da3f1deab7 --- /dev/null +++ b/src/ifcmcp/ifcmcp/__main__.py @@ -0,0 +1,48 @@ +# This file was generated with the assistance of an AI coding tool. +import argparse + +from ifcmcp import __version__ + + +def main(): + parser = argparse.ArgumentParser( + prog="python3 -m ifcmcp", + description=( + "ifcmcp — MCP server for IFC building models.\n\n" + "Runs a Model Context Protocol server over stdio so that MCP clients\n" + "can query and edit IFC files without writing them to disk between\n" + "operations.\n\n" + "Add to .mcp.json to configure:\n" + ' {"mcpServers": {"ifc": {"type": "stdio", "command": "python3", "args": ["-m", "ifcmcp"]}}}' + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--version", action="version", version=f"ifcmcp {__version__}") + parser.add_argument( + "--transport", + choices=["stdio", "sse", "streamable-http"], + default="stdio", + help="MCP transport to use (default: stdio)", + ) + + args = parser.parse_args() + + try: + from mcp.server.fastmcp import FastMCP # noqa: F401 + except ImportError: + import sys + + print( + "error: the 'mcp' package is required to run the server.\n" "Install it with: pip install mcp", + file=sys.stderr, + ) + sys.exit(1) + + from ifcmcp.server import build_server + + server = build_server() + server.run(transport=args.transport) + + +if __name__ == "__main__": + main() diff --git a/src/ifcmcp/ifcmcp/core.py b/src/ifcmcp/ifcmcp/core.py new file mode 100644 index 0000000000..137cdbdce0 --- /dev/null +++ b/src/ifcmcp/ifcmcp/core.py @@ -0,0 +1,741 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +# inside ifcmcp/core.py +import json +from collections.abc import Callable # noqa: F401 — Callable used in helpers below +from dataclasses import dataclass +from typing import Any + +import ifcopenshell +from ifcedit.discover import function_docs, list_functions, list_modules +from ifcedit.quantify import run_quantify +from ifcedit.run import run_api +from ifcquery import clash as clash_mod +from ifcquery import contexts as contexts_mod +from ifcquery import cost as cost_mod +from ifcquery import ( + info, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + plot as plot_mod, +) +from ifcquery import ( + render as render_mod, +) +from ifcquery import validate as validate_mod + + +def _jsonify(x: Any) -> Any: + """Convert IfcOpenShell objects / iterables into JSON-safe primitives.""" + if x is None or isinstance(x, (str, int, float, bool)): + return x + + # numpy arrays (and any array-like with tolist) + if hasattr(x, "tolist"): + return x.tolist() + + # IfcOpenShell entity instances: normalize + if isinstance(x, ifcopenshell.entity_instance): + return { + "id": int(x.id()), + "type": x.is_a(), + "repr": str(x), + "name": getattr(x, "Name", None), + } + + if isinstance(x, dict): + return {str(k): _jsonify(v) for k, v in x.items()} + + if isinstance(x, (list, tuple, set)): + return [_jsonify(v) for v in x] + + # Try JSON as-is, else fallback to string + try: + json.dumps(x) + return x + except Exception: + return str(x) + + +# --------------------------------------------------------------------------- +# Shape builder helpers +# --------------------------------------------------------------------------- + + +def _list_shape_methods() -> list[dict]: + """Introspect ShapeBuilder and return a summary of all public methods.""" + import inspect + + from ifcedit.discover import _extract_params + from ifcopenshell.util.shape_builder import ShapeBuilder + + results = [] + for name, fn in inspect.getmembers(ShapeBuilder, predicate=inspect.isfunction): + if name.startswith("_"): + continue + doc = fn.__doc__ or "" + description = doc.strip().split("\n")[0] if doc.strip() else "" + results.append({"method": name, "description": description, "params": _extract_params(fn)}) + return results + + +def _shape_method_docs(method_name: str) -> dict: + """Return full documentation for a single ShapeBuilder method.""" + import typing + + from ifcedit.discover import ( + _extract_params, + _format_type_hint, + _parse_docstring_body, + _parse_param_docs, + _parse_return_doc, + ) + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method_name.startswith("_"): + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + fn = getattr(ShapeBuilder, method_name, None) + if fn is None: + raise ValueError(f"ShapeBuilder has no method '{method_name}'") + + doc = fn.__doc__ or "" + description, long_description = _parse_docstring_body(doc) + params = _extract_params(fn) + for param in params: + param_desc = _parse_param_docs(doc) + if param["name"] in param_desc: + param["description"] = param_desc[param["name"]] + + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + result: dict[str, Any] = { + "method": method_name, + "description": description, + "long_description": long_description, + "params": params, + } + return_type = _format_type_hint(hints.get("return")) + if return_type: + result["return_type"] = return_type + return_description = _parse_return_doc(doc) + if return_description: + result["return_description"] = return_description + return result + + +def _coerce_shape_params(fn: Callable, raw_kwargs: dict, model: ifcopenshell.file) -> dict: + """Coerce JSON-parsed kwargs to proper Python types for a ShapeBuilder method.""" + import inspect + import typing + + sig = inspect.signature(fn) + try: + hints = typing.get_type_hints(fn) + except Exception: + hints = {} + + return { + key: _coerce_shape_value(value, hints.get(key), model) + for key, value in raw_kwargs.items() + if key in sig.parameters and key != "self" + } + + +def _coerce_shape_value(value: Any, hint: Any, model: ifcopenshell.file) -> Any: + """Convert a single JSON-parsed value to the correct Python type.""" + import typing + + if hint is None or value is None: + return value + + origin = typing.get_origin(hint) + args = typing.get_args(hint) + + # Optional[X] / Union — try each non-None branch in order + if origin is typing.Union: + if value is None: + return None + for t in (a for a in args if a is not type(None)): + try: + return _coerce_shape_value(value, t, model) + except (ValueError, TypeError): + continue + return value + + # entity_instance: resolve integer or "#N" string step ID + if hint is ifcopenshell.entity_instance or ( + isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance) + ): + entity_id = int(str(value).lstrip("#")) + entity = model.by_id(entity_id) + if entity is None: + raise ValueError(f"Entity #{entity_id} not found in model") + return entity + + # Sequence[entity_instance]: resolve each element in the list + import collections.abc + + if origin is not None and issubclass(origin, collections.abc.Sequence) and not isinstance(value, str): + if args and ( + args[0] is ifcopenshell.entity_instance + or (isinstance(args[0], type) and issubclass(args[0], ifcopenshell.entity_instance)) + ): + if isinstance(value, (list, tuple)): + return [_coerce_shape_value(v, args[0], model) for v in value] + + # bool: JSON gives actual bools; also accept string representations + if hint is bool: + if isinstance(value, bool): + return value + return str(value).lower() in ("true", "1", "yes") + + # Everything else (float, int, VectorType lists, dicts, Literals) passes through + return value + + +class IfcSessionError(RuntimeError): + pass + + +@dataclass +class IfcSession: + """In-memory IFC session (no FastMCP dependency). + + Designed to work in: + - FastMCP server (single global session) + - Embedded runtimes like Pyodide (one session per browser tab/worker) + """ + + model: ifcopenshell.file | None = None + model_path: str | None = None + + # ----------------- + # Session lifecycle + # ----------------- + def _require_model(self) -> ifcopenshell.file: + if self.model is None: + raise IfcSessionError("No model loaded. Call ifc_load() or ifc_new() first.") + return self.model + + def ifc_new(self, schema: str = "IFC4") -> dict[str, Any]: + """Create a new empty IFC model in memory.""" + self.model = ifcopenshell.file(schema=schema) + self.model_path = None + return {"ok": True, "schema": self.model.schema, "entities": sum(1 for _ in self.model)} + + def ifc_load(self, path: str) -> str: + """Open an IFC file into memory. Returns confirmation string.""" + self.model = ifcopenshell.open(path) + self.model_path = path + count = sum(1 for _ in self.model) + return f"Loaded {path}: schema {self.model.schema}, {count} entities" + + def ifc_save(self, path: str = "") -> str: + """Write the in-memory model to disk. Empty path overwrites the original file.""" + model = self._require_model() + target = path if path else self.model_path + if not target: + raise IfcSessionError("No path specified and no original path available.") + model.write(target) + return f"Saved to {target}" + + def ifc_reset(self) -> dict[str, Any]: + """Drop the in-memory model.""" + self.model = None + self.model_path = None + return {"ok": True} + + # ------------- + # Query tools + # ------------- + def ifc_summary(self) -> dict[str, Any]: + """Model overview: schema, entity counts, project info.""" + return summary.summary(self._require_model()) + + def ifc_tree(self) -> dict[str, Any] | list[dict[str, Any]]: + """Full spatial hierarchy tree (Project -> Site -> Building -> Storeys -> Elements).""" + return tree.tree(self._require_model()) + + def ifc_info(self, element_id: int) -> dict[str, Any]: + """Deep inspection of an entity by step ID (attributes, psets, placement, type, material).""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return info.info(model, element) + + def ifc_select(self, query: str) -> list[dict[str, Any]]: + """Filter elements using ifcopenshell selector syntax. + + Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``, + ``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``, + ``material = "Concrete"``. + """ + return select.select(self._require_model(), query) + + def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + """Show relationships for an element. Set traverse='up' to walk hierarchy to IfcProject.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return relations.relations(model, element, traverse=traverse if traverse else None) + + def ifc_clash( + self, + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + """Check element for geometric clashes. clearance=0.0 means no clearance check.""" + model = self._require_model() + element = model.by_id(element_id) + if element is None: + raise IfcSessionError(f"Element #{element_id} not found.") + return clash_mod.clash( + model, + element, + clearance=clearance if clearance and clearance > 0.0 else None, + tolerance=tolerance, + scope=scope, + ) + + def ifc_contexts(self) -> list[dict[str, Any]]: + """List all geometric representation contexts and subcontexts with their step IDs.""" + return contexts_mod.contexts(self._require_model()) + + def ifc_materials(self) -> list[dict[str, Any]]: + """List all materials and material sets (layers, constituents, profiles).""" + return materials_mod.materials(self._require_model()) + + # ------------------------ + # Edit discovery + execute + # ------------------------ + def ifc_list(self, module: str = "") -> list[dict]: + """List all API modules, or functions within a module. Empty module = all modules.""" + return list_functions(module) if module else list_modules() + + def ifc_docs(self, function_path: str) -> dict: + """Show full documentation for an API function. Input format: 'module.function'.""" + module, function = function_path.split(".", 1) + return function_docs(module, function) + + def ifc_edit(self, function_path: str, params: Any = "{}") -> dict: + """Execute an ifcopenshell.api mutation. + + params may be: + - JSON string + - dict (from tool calling / JS) + - JsProxy (handled upstream in embedded.py) + """ + model = self._require_model() + module, function = function_path.split(".", 1) + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + # e.g. list/None/etc + raw_kwargs = dict(params) if params is not None else {} + + res = run_api(model, module, function, raw_kwargs) + return _jsonify(res) + + # ------------------------ + # Extended query + edit tools + # ------------------------ + def ifc_validate(self, express_rules: bool = False) -> dict[str, Any]: + """Validate the loaded model. Returns {'valid': bool, 'issues': [...]}.""" + return validate_mod.validate(self._require_model(), express_rules=express_rules) + + def ifc_schedule(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List work schedules and nested tasks from the model. + + max_depth limits subtask expansion (None = unlimited). At the cutoff, + subtasks is replaced with {"truncated": True, "count": N}. + """ + return schedule.schedule(self._require_model(), max_depth=max_depth) + + def ifc_cost(self, max_depth: int | None = None) -> list[dict[str, Any]]: + """List cost schedules and nested cost items from the model. + + max_depth limits cost item expansion (None = unlimited). At the cutoff, + subitems is replaced with {"truncated": True, "count": N}. + """ + return cost_mod.cost(self._require_model(), max_depth=max_depth) + + def ifc_schema(self, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type using the model's schema version.""" + return schema.schema(self._require_model(), entity_type) + + def ifc_plot( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_format: str = "png", + ) -> bytes: + """Generate a 2D technical drawing (floor plan, elevation, or section) and return image bytes. + + Uses ifcopenshell.draw to produce SVG output which is rasterised to PNG via CairoSVG + when output_format is 'png'. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded to 10% opacity so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4). + :param height_mm: Paper height in mm (default 420 = A4). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_format: ``'svg'`` or ``'png'`` (default ``'png'``). + :return: SVG or PNG bytes depending on output_format. + """ + model = self._require_model() + return plot_mod.plot( + model, + output_format=output_format, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + ) + + def ifc_render( + self, + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + ) -> bytes: + """Render the loaded model to a PNG image and return raw bytes. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``). Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey. + :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``, + ``east``, or ``west``. + :return: PNG image as raw bytes. + """ + model = self._require_model() + return render_mod.render( + model, + selector=selector if selector else None, + element_ids=element_ids, + view=view, + ) + + # ------------------------ + # Shape builder tools + # ------------------------ + def ifc_shape_list(self) -> list[dict]: + """List all ShapeBuilder geometry methods with one-line descriptions and parameter names.""" + return _list_shape_methods() + + def ifc_shape_docs(self, method: str) -> dict: + """Full documentation for a ShapeBuilder method: params, types, return value.""" + return _shape_method_docs(method) + + def ifc_shape(self, method: str, params: Any = "{}") -> dict: + """Call a ShapeBuilder method by name. Returns the created entity's step ID. + + params is a JSON string of keyword arguments. Pass entity references as integer + step IDs; vectors as JSON arrays (e.g. [1.0, 0.0, 0.0]). + """ + model = self._require_model() + + from ifcopenshell.util.shape_builder import ShapeBuilder + + if method.startswith("_"): + raise IfcSessionError(f"Private method '{method}' is not accessible") + fn = getattr(ShapeBuilder, method, None) + if fn is None: + return {"ok": False, "error": f"ShapeBuilder has no method '{method}'"} + + if isinstance(params, str): + raw_kwargs = json.loads(params) if params.strip() else {} + elif isinstance(params, dict): + raw_kwargs = params + else: + raw_kwargs = {} + + try: + coerced = _coerce_shape_params(fn, raw_kwargs, model) + result = fn(ShapeBuilder(model), **coerced) + return {"ok": True, "result": _jsonify(result)} + except Exception as e: + return {"ok": False, "error": f"{type(e).__name__}: {e}"} + + def ifc_quantify(self, rule: str, selector: str = "") -> dict[str, Any]: + """Run quantity take-off on the model using the named rule. + + Modifies the model in-place; call ifc_save() after. + """ + model = self._require_model() + return run_quantify(model, rule, selector=selector if selector else None) + + # ------------------------ + # Generic dispatcher + tool specs for LLMs + # ------------------------ + def dispatch(self, name: str, args: dict[str, Any] | None = None) -> Any: + args = args or {} + fn = getattr(self, name, None) + if not callable(fn): + raise IfcSessionError(f"Unknown tool: {name}") + return _jsonify(fn(**args)) + + def openai_tools(self) -> list[dict[str, Any]]: + """Tool schemas in the OpenAI 'Responses API' format (type=function).""" + # Keep schemas tight so the model calls tools correctly. + return [ + { + "type": "function", + "name": "ifc_new", + "description": "Create a new empty IFC model in memory.", + "parameters": { + "type": "object", + "properties": {"schema": {"type": "string", "description": "IFC schema, e.g. IFC4"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_summary", + "description": "Get a concise overview of the loaded IFC model.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_tree", + "description": "Get the full spatial hierarchy tree.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_select", + "description": ( + "Select elements using ifcopenshell selector syntax. " + "Examples: 'IfcWall', 'IfcWall, IfcColumn', '! IfcWall', " + "'IfcWall, Name = \"My Wall\"', 'type = \"Concrete Wall\"', " + "'material = \"Concrete\"'." + ), + "parameters": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_info", + "description": "Inspect an entity by STEP id.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_relations", + "description": "Get relationships for an element. traverse='up' walks to IfcProject.", + "parameters": { + "type": "object", + "properties": {"element_id": {"type": "integer"}, "traverse": {"type": "string"}}, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_clash", + "description": "Run clash/clearance checks for an element.", + "parameters": { + "type": "object", + "properties": { + "element_id": {"type": "integer"}, + "clearance": {"type": "number"}, + "tolerance": {"type": "number"}, + "scope": {"type": "string", "description": "storey or all"}, + }, + "required": ["element_id"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_contexts", + "description": "List all geometric representation contexts and subcontexts with their step IDs, context type, identifier, and target view. Use this to find the context ID required for geometry-creation API calls.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_materials", + "description": "List all materials and material sets (IfcMaterial, IfcMaterialLayerSet, IfcMaterialConstituentSet, IfcMaterialProfileSet) with their layers, constituents, or profiles.", + "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + }, + { + "type": "function", + "name": "ifc_list", + "description": "List ifcopenshell.api modules or functions within a module.", + "parameters": { + "type": "object", + "properties": {"module": {"type": "string"}}, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_docs", + "description": "Get documentation for an ifcopenshell.api function, 'module.function'.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_edit", + "description": "Execute an ifcopenshell.api mutation; params is a JSON string of stringly-typed kwargs.", + "parameters": { + "type": "object", + "properties": {"function_path": {"type": "string"}, "params": {"type": "string"}}, + "required": ["function_path"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_validate", + "description": "Validate the loaded model. Returns valid bool and list of issues.", + "parameters": { + "type": "object", + "properties": { + "express_rules": {"type": "boolean", "description": "Also check EXPRESS rules (slower)"} + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schedule", + "description": "List work schedules and nested tasks. Use max_depth=1 for top-level phases only on large projects.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of subtask expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_cost", + "description": "List cost schedules and nested cost items. Use max_depth=1 for top-level sections only on large BoQs.", + "parameters": { + "type": "object", + "properties": { + "max_depth": { + "type": "integer", + "description": "Max levels of cost item expansion (omit for unlimited)", + } + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_schema", + "description": "Return IFC class documentation for an entity type.", + "parameters": { + "type": "object", + "properties": {"entity_type": {"type": "string", "description": "IFC entity type, e.g. IfcWall"}}, + "required": ["entity_type"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_quantify", + "description": "Run quantity take-off (QTO) on the model. Modifies model in-place; call ifc_save() after.", + "parameters": { + "type": "object", + "properties": { + "rule": {"type": "string", "description": "QTO rule name, e.g. IFC4QtoBaseQuantities"}, + "selector": { + "type": "string", + "description": "ifcopenshell selector to restrict elements (default: all IfcElement)", + }, + }, + "required": ["rule"], + "additionalProperties": False, + }, + }, + { + "type": "function", + "name": "ifc_render", + "description": ( + "Render the loaded IFC model to a PNG image for visual inspection. " + "Use selector to restrict which elements are rendered (e.g. a single storey). " + "Use element_ids to highlight elements against a greyed-out background. " + "Returns base64-encoded PNG bytes." + ), + "parameters": { + "type": "object", + "properties": { + "selector": {"type": "string", "description": "ifcopenshell selector (default: whole model)"}, + "element_ids": { + "type": "array", + "items": {"type": "integer"}, + "description": "Step IDs of elements to highlight", + }, + "view": { + "type": "string", + "enum": ["iso", "top", "south", "north", "east", "west"], + "description": "Camera angle (default: iso)", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, + ] diff --git a/src/ifcmcp/ifcmcp/embedded.py b/src/ifcmcp/ifcmcp/embedded.py new file mode 100644 index 0000000000..4ad99ba1fa --- /dev/null +++ b/src/ifcmcp/ifcmcp/embedded.py @@ -0,0 +1,60 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from ifcmcp.core import IfcSession + +session = IfcSession() + +# Optional imports only available under Pyodide +try: + from pyodide.ffi import JsProxy, to_py # type: ignore +except Exception: # pragma: no cover + JsProxy = None # type: ignore + to_py = None # type: ignore + + +def _coerce_args(args: Any) -> dict[str, Any]: + """Convert JS objects / JsProxy / mappings into a real Python dict.""" + if args is None: + return {} + + # Pyodide: JS object arrives as JsProxy; convert recursively to Python. + if JsProxy is not None and isinstance(args, JsProxy): + # dict_converter=dict ensures JS object -> Python dict (not Map) + return to_py(args, dict_converter=dict) + + # Already a Python dict + if isinstance(args, dict): + return args + + # Any Mapping-like object + if isinstance(args, Mapping): + return dict(args) + + # Last resort: try dict() coercion + try: + return dict(args) + except Exception as e: + raise TypeError(f"Tool args must be a mapping/dict; got {type(args)}") from e + + +def tools_openai() -> list[dict[str, Any]]: + return session.openai_tools() + + +def call_tool(name: str, args: Any = None) -> dict[str, Any]: + """ + Non-throwing tool dispatcher. + Always returns: {"ok": bool, "data": ...} or {"ok": false, "error": "...", "error_type": "...", ...} + """ + try: + py_args = _coerce_args(args) + data = session.dispatch(name, py_args) + return {"ok": True, "data": data} + + except Exception as e: + # Keep it short; avoid full tracebacks in tool output unless debugging. + return {"ok": False, "error_type": type(e).__name__, "error": str(e)} diff --git a/src/ifcmcp/ifcmcp/server.py b/src/ifcmcp/ifcmcp/server.py new file mode 100644 index 0000000000..08ba86a2cb --- /dev/null +++ b/src/ifcmcp/ifcmcp/server.py @@ -0,0 +1,231 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import base64 +from typing import Any + +from ifcmcp.core import IfcSession + +try: + from mcp.server.fastmcp import FastMCP # type: ignore + from mcp.types import ImageContent # type: ignore +except Exception: # pragma: no cover + FastMCP = None # type: ignore + ImageContent = None # type: ignore + + +def build_server() -> Any: + """Create the FastMCP server if the dependency is available.""" + if FastMCP is None: + raise ImportError( + "FastMCP is not installed. Install with: pip install ifcmcp[mcp] " "(or add 'mcp' to your environment)." + ) + + session = IfcSession() + + server = FastMCP( + name="ifc-mcp", + instructions=( + "MCP server for querying and editing IFC building models. " + "Load a file first with ifc_load, then use query/edit tools. " + "Save changes with ifc_save." + ), + ) + + # ---- Lifecycle ---- + @server.tool() + def ifc_new(schema: str = "IFC4") -> dict[str, Any]: + return session.ifc_new(schema=schema) + + @server.tool() + def ifc_load(path: str) -> str: + return session.ifc_load(path) + + @server.tool() + def ifc_save(path: str = "") -> str: + return session.ifc_save(path) + + @server.tool() + def ifc_reset() -> dict[str, Any]: + return session.ifc_reset() + + # ---- Query ---- + @server.tool() + def ifc_summary() -> dict[str, Any]: + return session.ifc_summary() + + @server.tool() + def ifc_tree() -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_tree() + + @server.tool() + def ifc_info(element_id: int) -> dict[str, Any]: + return session.ifc_info(element_id) + + @server.tool() + def ifc_select(query: str) -> list[dict[str, Any]]: + return session.ifc_select(query) + + @server.tool() + def ifc_relations(element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]: + return session.ifc_relations(element_id, traverse=traverse) + + @server.tool() + def ifc_clash( + element_id: int, + clearance: float = 0.0, + tolerance: float = 0.002, + scope: str = "storey", + ) -> dict[str, Any]: + return session.ifc_clash( + element_id=element_id, + clearance=clearance, + tolerance=tolerance, + scope=scope, + ) + + @server.tool() + def ifc_contexts() -> list[dict[str, Any]]: + return session.ifc_contexts() + + @server.tool() + def ifc_materials() -> list[dict[str, Any]]: + return session.ifc_materials() + + # ---- Edit ---- + @server.tool() + def ifc_list(module: str = "") -> list[dict]: + return session.ifc_list(module=module) + + @server.tool() + def ifc_docs(function_path: str) -> dict: + return session.ifc_docs(function_path=function_path) + + @server.tool() + def ifc_edit(function_path: str, params: str = "{}") -> dict: + return session.ifc_edit(function_path=function_path, params=params) + + # ---- Extended query + edit ---- + @server.tool() + def ifc_validate(express_rules: bool = False) -> dict[str, Any]: + return session.ifc_validate(express_rules=express_rules) + + @server.tool() + def ifc_schedule(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_schedule(max_depth=max_depth) + + @server.tool() + def ifc_cost(max_depth: int | None = None) -> list[dict[str, Any]]: + return session.ifc_cost(max_depth=max_depth) + + @server.tool() + def ifc_schema(entity_type: str) -> dict[str, Any]: + return session.ifc_schema(entity_type=entity_type) + + @server.tool() + def ifc_quantify(rule: str, selector: str = "") -> dict[str, Any]: + return session.ifc_quantify(rule=rule, selector=selector) + + # ---- Shape builder ---- + @server.tool() + def ifc_shape_list() -> list[dict]: + return session.ifc_shape_list() + + @server.tool() + def ifc_shape_docs(method: str) -> dict: + return session.ifc_shape_docs(method=method) + + @server.tool() + def ifc_shape(method: str, params: str = "{}") -> dict: + return session.ifc_shape(method=method, params=params) + + @server.tool(structured_output=False) + def ifc_plot( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "floorplan", + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + png_width: int = 1024, + png_height: int = 1024, + output_path: str = "", + ) -> list[ImageContent]: + """Generate a 2D technical drawing of the loaded IFC model. + + Returns an inline PNG image (floor plan, elevation, or section) that the + LLM can inspect to understand the 2D layout of the model. If + ``output_path`` is provided the drawing is also saved to disk — as SVG + when the path ends in ``.svg``, otherwise as PNG. + + :param selector: ifcopenshell selector to restrict plotted elements + (e.g. ``'IfcWall'``). Omit to plot the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are faded so the subject stands out. + :param view: Drawing view — ``floorplan`` (default), ``elevation``, + ``section``, or ``auto``. + :param width_mm: Paper width in mm (default 297 = A4 landscape width). + :param height_mm: Paper height in mm (default 420 = A4 landscape height). + :param scale: Model-to-paper scale ratio (default 0.01 = 1:100). + :param png_width: Raster output width in pixels (default 1024). + :param png_height: Raster output height in pixels (default 1024). + :param output_path: Optional file path to save the drawing to disk. + """ + png_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + png_width=png_width, + png_height=png_height, + output_format="png", + ) + if output_path: + if output_path.endswith(".svg"): + svg_bytes = session.ifc_plot( + selector=selector, + element_ids=element_ids, + view=view, + width_mm=width_mm, + height_mm=height_mm, + scale=scale, + output_format="svg", + ) + with open(output_path, "wb") as f: + f.write(svg_bytes) + else: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + @server.tool(structured_output=False) + def ifc_render( + selector: str = "", + element_ids: list[int] | None = None, + view: str = "iso", + output_path: str = "", + ) -> list[ImageContent]: + """Render the loaded IFC model to a PNG image. + + Returns an inline image the LLM can inspect to understand the spatial + layout of the model or a specific element in context. If + ``output_path`` is provided the PNG is also saved to that file path. + + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcBuildingStorey[Name="0"]'``). + Omit to render the whole model. + :param element_ids: Step IDs of elements to highlight. Other elements + are rendered in translucent grey so the subject stands out. + :param view: Camera angle — ``iso`` (default), ``top``, ``south``, + ``north``, ``east``, or ``west``. + :param output_path: Optional file path to save the PNG to disk. + """ + png_bytes = session.ifc_render(selector=selector, element_ids=element_ids, view=view) + if output_path: + with open(output_path, "wb") as f: + f.write(png_bytes) + return [ImageContent(type="image", data=base64.b64encode(png_bytes).decode(), mimeType="image/png")] + + return server diff --git a/src/ifcmcp/pyproject.toml b/src/ifcmcp/pyproject.toml new file mode 100644 index 0000000000..295954d0c5 --- /dev/null +++ b/src/ifcmcp/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcopenshell-mcp" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "MCP server for querying and editing IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "MCP"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell", "ifcquery", "ifcedit"] + +[project.optional-dependencies] +mcp = ["mcp"] + +[project.scripts] +ifcmcp = "ifcmcp.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcmcp*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcmcp/tests/__init__.py b/src/ifcmcp/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcmcp/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcmcp/tests/conftest.py b/src/ifcmcp/tests/conftest.py new file mode 100644 index 0000000000..9fae4aef92 --- /dev/null +++ b/src/ifcmcp/tests/conftest.py @@ -0,0 +1,59 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcmcp.core import IfcSession + + +@pytest.fixture +def session(): + return IfcSession() + + +@pytest.fixture +def model(): + """IFC4 model with a spatial hierarchy, a wall, and a slab.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f + + +@pytest.fixture +def model_file(model, tmp_path): + """Write the model fixture to a temp file and return the path.""" + path = tmp_path / "test.ifc" + model.write(str(path)) + return str(path) + + +@pytest.fixture +def loaded_session(model): + """An IfcSession with an in-memory model already loaded (no file path).""" + s = IfcSession() + s.model = model + return s diff --git a/src/ifcmcp/tests/test_edit.py b/src/ifcmcp/tests/test_edit.py new file mode 100644 index 0000000000..778b232249 --- /dev/null +++ b/src/ifcmcp/tests/test_edit.py @@ -0,0 +1,96 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestNoModel: + def test_edit_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_edit("root.create_entity") + + +class TestList: + def test_list_all_modules(self, loaded_session): + result = loaded_session.ifc_list() + assert isinstance(result, list) + assert len(result) > 0 + modules = [m["module"] for m in result] + assert "root" in modules + assert "spatial" in modules + + def test_list_module_functions(self, loaded_session): + result = loaded_session.ifc_list(module="root") + assert isinstance(result, list) + names = [f["name"] for f in result] + assert "create_entity" in names + + def test_list_empty_string_returns_modules(self, loaded_session): + result = loaded_session.ifc_list(module="") + assert isinstance(result, list) + assert any(m["module"] == "root" for m in result) + + +class TestDocs: + def test_docs_create_entity(self, loaded_session): + result = loaded_session.ifc_docs("root.create_entity") + assert result["module"] == "root" + assert result["function"] == "create_entity" + assert "params" in result + + def test_docs_bad_format(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_docs("no_dot_here") + + +class TestEdit: + def test_create_entity(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "NewWall"})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWall" + assert result["result"]["name"] == "NewWall" + + def test_create_entity_default_params(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", "{}") + assert result["ok"] is True + + def test_unknown_function(self, loaded_session): + result = loaded_session.ifc_edit("root.nonexistent", "{}") + assert result["ok"] is False + assert "Cannot find" in result["error"] + + def test_unknown_parameter(self, loaded_session): + result = loaded_session.ifc_edit("root.create_entity", json.dumps({"bogus": "value"})) + assert result["ok"] is False + assert "Unknown parameter" in result["error"] + + def test_bad_json(self, loaded_session): + with pytest.raises(json.JSONDecodeError): + loaded_session.ifc_edit("root.create_entity", "not json") + + def test_edit_does_not_save(self, loaded_session, tmp_path): + """Verify that ifc_edit mutates the in-memory model but does not write to disk.""" + path = str(tmp_path / "test.ifc") + loaded_session.model.write(path) + loaded_session.model_path = path + + before_count = sum(1 for _ in loaded_session.model) + loaded_session.ifc_edit("root.create_entity", json.dumps({"ifc_class": "IfcWall", "name": "Unsaved"})) + after_count = sum(1 for _ in loaded_session.model) + assert after_count == before_count + 1 + + on_disk = ifcopenshell.open(path) + disk_count = sum(1 for _ in on_disk) + assert disk_count == before_count + + def test_assign_container(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + storey = loaded_session.model.by_type("IfcBuildingStorey")[0] + result = loaded_session.ifc_edit( + "spatial.assign_container", + json.dumps({"products": str(wall.id()), "relating_structure": str(storey.id())}), + ) + assert result["ok"] is True diff --git a/src/ifcmcp/tests/test_query.py b/src/ifcmcp/tests/test_query.py new file mode 100644 index 0000000000..e0a28ae1c0 --- /dev/null +++ b/src/ifcmcp/tests/test_query.py @@ -0,0 +1,113 @@ +# This file was generated with the assistance of an AI coding tool. +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestNoModel: + """All query tools should fail when no model is loaded.""" + + def test_summary_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_summary() + + def test_tree_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_tree() + + def test_info_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_info(1) + + def test_select_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_select("IfcWall") + + def test_relations_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_relations(1) + + +class TestSummary: + def test_schema(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["schema"] == "IFC4" + + def test_total_entities(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["total_entities"] > 0 + + def test_project_name(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, loaded_session): + result = loaded_session.ifc_summary() + assert result["types"]["IfcWall"] == 1 + assert result["types"]["IfcSlab"] == 1 + + +class TestTree: + def test_root_is_project(self, loaded_session): + result = loaded_session.ifc_tree() + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_hierarchy_depth(self, loaded_session): + result = loaded_session.ifc_tree() + site = result["children"][0] + assert site["type"] == "IfcSite" + building = site["children"][0] + assert building["type"] == "IfcBuilding" + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + + +class TestInfo: + def test_wall_info(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_info(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + + def test_invalid_id(self, loaded_session): + with pytest.raises(Exception): + loaded_session.ifc_info(999999) + + +class TestSelect: + def test_select_walls(self, loaded_session): + result = loaded_session.ifc_select("IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_slabs(self, loaded_session): + result = loaded_session.ifc_select("IfcSlab") + assert len(result) == 1 + assert result[0]["name"] == "Slab001" + + def test_select_no_match(self, loaded_session): + result = loaded_session.ifc_select("IfcWindow") + assert result == [] + + +class TestRelations: + def test_wall_relations(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id()) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert "hierarchy" in result + + def test_traverse_up(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="up") + assert isinstance(result, list) + assert result[0]["type"] == "IfcWall" + assert result[-1]["type"] == "IfcProject" + + def test_traverse_empty_string_means_no_traverse(self, loaded_session): + wall = loaded_session.model.by_type("IfcWall")[0] + result = loaded_session.ifc_relations(wall.id(), traverse="") + assert isinstance(result, dict) diff --git a/src/ifcmcp/tests/test_server.py b/src/ifcmcp/tests/test_server.py new file mode 100644 index 0000000000..ed41434df7 --- /dev/null +++ b/src/ifcmcp/tests/test_server.py @@ -0,0 +1,106 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import pytest + +from ifcmcp.server import build_server + + +class TestServerRegistration: + def test_server_name(self): + server = build_server() + assert server.name == "ifc-mcp" + + def test_all_tools_registered(self): + server = build_server() + tools = [t.name for t in server._tool_manager.list_tools()] + expected = [ + "ifc_load", + "ifc_save", + "ifc_summary", + "ifc_tree", + "ifc_info", + "ifc_select", + "ifc_relations", + "ifc_clash", + "ifc_list", + "ifc_docs", + "ifc_edit", + ] + for name in expected: + assert name in tools, f"Tool {name} not registered" + + +@pytest.fixture +def tool_fns(): + """Return a dict of tool name → raw function from a freshly built server.""" + server = build_server() + return {t.name: t.fn for t in server._tool_manager.list_tools()} + + +PNG_FAKE = b"\x89PNG\r\n\x1a\nFAKE" +SVG_FAKE = b"FAKE" + + +class TestRenderOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path="") + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_file(self, tool_fns, tmp_path): + out = str(tmp_path / "render.png") + with patch("ifcmcp.core.IfcSession.ifc_render", return_value=PNG_FAKE): + tool_fns["ifc_render"](selector="", element_ids=None, view="iso", output_path=out) + assert open(out, "rb").read() == PNG_FAKE + + +class TestPlotOutputPath: + def test_no_output_path_no_file_written(self, tool_fns, tmp_path): + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path="", + ) + assert list(tmp_path.iterdir()) == [] + + def test_png_output_path_writes_png(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.png") + with patch("ifcmcp.core.IfcSession.ifc_plot", return_value=PNG_FAKE): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == PNG_FAKE + + def test_svg_output_path_writes_svg(self, tool_fns, tmp_path): + out = str(tmp_path / "plot.svg") + # ifc_plot is called twice: once with "png" for the inline image, + # once with "svg" for the file. + with patch("ifcmcp.core.IfcSession.ifc_plot", side_effect=[PNG_FAKE, SVG_FAKE]): + tool_fns["ifc_plot"]( + selector="", + element_ids=None, + view="floorplan", + width_mm=297.0, + height_mm=420.0, + scale=0.01, + png_width=1024, + png_height=1024, + output_path=out, + ) + assert open(out, "rb").read() == SVG_FAKE diff --git a/src/ifcmcp/tests/test_session.py b/src/ifcmcp/tests/test_session.py new file mode 100644 index 0000000000..4fa59120c0 --- /dev/null +++ b/src/ifcmcp/tests/test_session.py @@ -0,0 +1,62 @@ +# This file was generated with the assistance of an AI coding tool. +from unittest.mock import patch + +import ifcopenshell +import pytest + +from ifcmcp.core import IfcSession, IfcSessionError + + +class TestLoad: + def test_load_file(self, session, model_file): + result = session.ifc_load(model_file) + assert "IFC4" in result + assert session.model is not None + assert session.model_path == model_file + + def test_load_sets_entity_count(self, session, model_file): + result = session.ifc_load(model_file) + assert "entities" in result + + def test_load_nonexistent_file(self, session): + with pytest.raises(Exception): + session.ifc_load("/nonexistent/path/model.ifc") + + +class TestSave: + def test_save_no_model(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_save() + + def test_save_overwrites_original(self, session, model_file): + session.ifc_load(model_file) + result = session.ifc_save() + assert model_file in result + + def test_save_to_new_path(self, session, model_file, tmp_path): + session.ifc_load(model_file) + new_path = str(tmp_path / "output.ifc") + result = session.ifc_save(new_path) + assert new_path in result + reloaded = ifcopenshell.open(new_path) + assert reloaded.schema == "IFC4" + + def test_save_no_path_no_original(self, loaded_session): + with pytest.raises(IfcSessionError, match="No path specified"): + loaded_session.ifc_save() + + +class TestIfcPlotOutputFormat: + """ifc_plot should pass output_format through to the underlying plot function.""" + + def test_default_output_format_is_png(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"PNG_FAKE") as mock_plot: + loaded_session.ifc_plot() + mock_plot.assert_called_once() + assert mock_plot.call_args.kwargs["output_format"] == "png" + + def test_svg_output_format(self, loaded_session): + with patch("ifcmcp.core.plot_mod.plot", return_value=b"SVG_FAKE") as mock_plot: + result = loaded_session.ifc_plot(output_format="svg") + assert result == b"SVG_FAKE" + assert mock_plot.call_args.kwargs["output_format"] == "svg" diff --git a/src/ifcmcp/tests/test_shape.py b/src/ifcmcp/tests/test_shape.py new file mode 100644 index 0000000000..1b657d5730 --- /dev/null +++ b/src/ifcmcp/tests/test_shape.py @@ -0,0 +1,141 @@ +# This file was generated with the assistance of an AI coding tool. +import json + +import pytest + +from ifcmcp.core import IfcSessionError + + +class TestShapeList: + def test_returns_list(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert isinstance(result, list) + assert len(result) > 0 + + def test_has_expected_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + names = [m["method"] for m in result] + assert "polyline" in names + assert "rectangle" in names + assert "extrude" in names + assert "profile" in names + assert "get_representation" in names + + def test_well_documented_methods_have_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_list() + by_name = {m["method"]: m for m in result} + # These methods have detailed docstrings + for name in ("polyline", "extrude", "rectangle", "profile", "get_representation"): + assert by_name[name]["description"], f"'{name}' has no description" + + def test_no_private_methods(self, loaded_session): + result = loaded_session.ifc_shape_list() + assert not any(m["method"].startswith("_") for m in result) + + def test_does_not_require_model(self, session): + # ifc_shape_list is pure introspection — no model needed + result = session.ifc_shape_list() + assert isinstance(result, list) + + +class TestShapeDocs: + def test_extrude_docs(self, loaded_session): + result = loaded_session.ifc_shape_docs("extrude") + assert result["method"] == "extrude" + assert result["description"] + assert "params" in result + param_names = [p["name"] for p in result["params"]] + assert "profile_or_curve" in param_names + assert "magnitude" in param_names + + def test_has_return_type(self, loaded_session): + result = loaded_session.ifc_shape_docs("rectangle") + assert "return_type" in result + + def test_has_param_descriptions(self, loaded_session): + result = loaded_session.ifc_shape_docs("polyline") + params_with_desc = [p for p in result["params"] if "description" in p] + assert len(params_with_desc) > 0 + + def test_unknown_method(self, loaded_session): + with pytest.raises(ValueError, match="no method"): + loaded_session.ifc_shape_docs("nonexistent_method") + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(ValueError): + loaded_session.ifc_shape_docs("__init__") + + def test_does_not_require_model(self, session): + result = session.ifc_shape_docs("circle") + assert result["method"] == "circle" + + +class TestShapeExecute: + def test_rectangle(self, loaded_session): + result = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcIndexedPolyCurve" + + def test_circle(self, loaded_session): + result = loaded_session.ifc_shape("circle", json.dumps({"center": [0.0, 0.0], "radius": 0.5})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcCircle" + + def test_extrude_chained_from_rectangle(self, loaded_session): + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [4.0, 0.2]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 3.0})) + assert result["ok"] is True + assert result["result"]["type"] == "IfcExtrudedAreaSolid" + + def test_entity_id_as_integer(self, loaded_session): + """Entity IDs should be accepted as plain integers (from JSON).""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [1.0, 1.0]})) + rect_id = rect["result"]["id"] + # Pass as int, not string + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": rect_id, "magnitude": 1.0})) + assert result["ok"] is True + + def test_rotate_2d_point_returns_list(self, loaded_session): + """Methods returning numpy arrays should give back plain lists.""" + result = loaded_session.ifc_shape( + "rotate_2d_point", json.dumps({"point_2d": [1.0, 0.0], "angle": 90.0, "counter_clockwise": True}) + ) + assert result["ok"] is True + assert isinstance(result["result"], list) + assert len(result["result"]) == 2 + + def test_set_polyline_coords_returns_none(self, loaded_session): + """In-place methods that return None should give ok=True, result=None.""" + rect = loaded_session.ifc_shape("rectangle", json.dumps({"size": [2.0, 2.0]})) + rect_id = rect["result"]["id"] + result = loaded_session.ifc_shape( + "set_polyline_coords", + json.dumps({"polyline": rect_id, "coords": [[0.0, 0.0], [3.0, 0.0], [3.0, 3.0], [0.0, 3.0]]}), + ) + assert result["ok"] is True + assert result["result"] is None + + def test_unknown_method(self, loaded_session): + result = loaded_session.ifc_shape("nonexistent_method", "{}") + assert result["ok"] is False + assert "error" in result + + def test_private_method_rejected(self, loaded_session): + with pytest.raises(IfcSessionError): + loaded_session.ifc_shape("__init__", "{}") + + def test_no_model_raises(self, session): + with pytest.raises(IfcSessionError, match="No model loaded"): + session.ifc_shape("rectangle", "{}") + + def test_params_as_dict(self, loaded_session): + """params can be passed as a dict (not just a JSON string).""" + result = loaded_session.ifc_shape("rectangle", {"size": [2.0, 1.0]}) + assert result["ok"] is True + + def test_error_on_bad_params(self, loaded_session): + """Bad parameters should give ok=False with an error message.""" + result = loaded_session.ifc_shape("extrude", json.dumps({"profile_or_curve": 999999, "magnitude": 1.0})) + assert result["ok"] is False + assert "error" in result diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 90c503d145..7d6592635d 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -5,8 +5,8 @@ VERSION_DATE:=$(shell date '+%y%m%d') PYVERSION:=py311 PLATFORM:=linux64 -PYTHON:=python3.11 -PIP:=pip3.11 +PYTHON:=python3 +PIP:=pip3 SED:=sed -i VENV_ACTIVATE:=bin/activate @@ -27,29 +27,15 @@ SED:=sed -i '' -e endif endif -# TODO: we should simplify this at some point... -ifeq ($(PYVERSION), py39) -PYNUMBER:=39 -endif -ifeq ($(PYVERSION), py310) -PYNUMBER:=310 -endif -ifeq ($(PYVERSION), py311) -PYNUMBER:=311 -endif -ifeq ($(PYVERSION), py312) -PYNUMBER:=312 -endif -ifeq ($(PYVERSION), py313) -PYNUMBER:=313 -endif -ifeq ($(PYVERSION), py314) -PYNUMBER:=314 -endif -ifndef PYNUMBER -$(error Unsupported PYVERSION '$(PYVERSION)') +SUPPORTED_PYVERSIONS := py310 py311 py312 py313 py314 + +ifeq ($(filter $(PYVERSION),$(SUPPORTED_PYVERSIONS)),) +$(error Unsupported PYVERSION=$(PYVERSION). Must be one of $(SUPPORTED_PYVERSIONS)) endif +PYMINOR:=$(subst py3,,$(PYVERSION)) +PYNUMBER:=3$(PYMINOR) + # We actually do support glibc 2.28-2.30 (see #5636) # but those are old and there's no demand for it. ifeq ($(PLATFORM), linux64) @@ -93,10 +79,6 @@ test-parallel: test-mathutils: pytest -p no:pytest-blender test/util/test_shape_builder.py -.PHONY: build-ids-docs -build-ids-docs: - mkdir -p test/build - cd test && python ids_doc_generator.py .PHONY: qa qa: diff --git a/src/ifcopenshell-python/docs/ifcedit.rst b/src/ifcopenshell-python/docs/ifcedit.rst new file mode 100644 index 0000000000..d5db9b7c82 --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcedit.rst @@ -0,0 +1,119 @@ +.. This file was generated with the assistance of an AI coding tool. + +IfcEdit +======= + +IfcEdit is a CLI wrapper for the full ``ifcopenshell.api`` mutation API. It +exposes all editor functions — over 350 across 30+ modules — without requiring +you to write a Python script. It supports four subcommands: + +- **list** — list all API modules, or all functions within a module +- **docs** — show full documentation for a function (parameters, types, descriptions) +- **run** — execute a mutation against an IFC file +- **foreach** — apply an API function to each element in a JSON array read from stdin +- **quantify** — run quantity take-off using ifc5d rules; requires the IfcOpenShell C++ geometry bindings + +Installation +------------ + +:: + + pip install ifcedit + +Or install from source: + +1. :doc:`Install IfcOpenShell ` +2. `Clone the IfcOpenShell repository `_. +3. ``cd /path/to/IfcOpenShell/src/ifcedit`` +4. ``pip install .`` + +Usage +----- + +Discover available API functions:: + + $ ifcedit list + $ ifcedit list root + $ ifcedit list geometry + +Read documentation for a function:: + + $ ifcedit docs root.remove_product + $ ifcedit docs type.assign_type + +Execute a mutation (overwrites the input file by default):: + + $ ifcedit run model.ifc root.remove_product --product 42 + $ ifcedit run model.ifc type.assign_type --related_objects 10 --relating_type 20 + +Write to a separate output file:: + + $ ifcedit run model.ifc root.create_entity -o output.ifc --ifc_class IfcWall + +Dry-run to validate without modifying the file:: + + $ ifcedit run model.ifc root.remove_product --dry-run --product 42 + +Apply an API function to each element in a JSON array from stdin (``{field}`` +placeholders are substituted from each item; model is opened and saved once):: + + $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + $ ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' + +Write to a separate output file instead of overwriting:: + + $ ifcquery model.ifc select 'IfcWall' | ifcedit foreach model.ifc root.remove_product -o output.ifc --product {id} + +Quantity take-off (writes ``IfcElementQuantity`` psets back to the file; requires C++ geometry bindings):: + + $ ifcedit quantify list + $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities + $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall + $ ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc + +Parameter types +--------------- + +IfcEdit automatically coerces CLI string arguments to the correct Python types +using the type hints on each API function: + +.. list-table:: + :header-rows: 1 + + * - Type + - CLI input + - Python value + * - ``str`` + - ``"hello"`` + - ``"hello"`` + * - ``int`` + - ``"42"`` or ``"#42"`` + - ``42`` + * - ``float`` + - ``"3.14"`` + - ``3.14`` + * - ``bool`` + - ``"true"``, ``"1"``, ``"yes"`` + - ``True`` + * - ``Optional[X]`` + - ``"none"`` + - ``None`` + * - ``entity_instance`` + - ``"42"`` or ``"#42"`` + - resolved from model by step ID + * - ``list[entity_instance]`` + - ``"5,6,7"`` + - list of resolved entities + * - ``dict`` + - ``'{"key": "val"}'`` + - parsed JSON object + * - ``Literal["A", "B"]`` + - ``"A"`` + - validated against allowed values + +.. seealso:: + + Use :doc:`IfcQuery ` for read-only inspection of IFC files, and + :doc:`IfcMCP ` for interactive AI-assisted editing with an in-memory + session. diff --git a/src/ifcopenshell-python/docs/ifcmcp.rst b/src/ifcopenshell-python/docs/ifcmcp.rst new file mode 100644 index 0000000000..2f7f0c39f0 --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcmcp.rst @@ -0,0 +1,110 @@ +.. This file was generated with the assistance of an AI coding tool. + +IfcMCP +====== + +IfcMCP is an MCP (Model Context Protocol) server that exposes IfcOpenShell +query and edit tools to AI coding assistants such as Claude. It wraps +:doc:`IfcQuery ` and :doc:`IfcEdit `, holding the IFC model +in memory across tool calls so no file I/O is required between operations. + +The ``ifcmcp`` package can also be used directly as a Python library without +the MCP server layer. + +Installation +------------ + +To use IfcMCP as an MCP server, install it together with the ``mcp`` package:: + + pip install 'ifcmcp[mcp]' + +If you only want to use the library directly (without an MCP client):: + + pip install ifcmcp + +Or install from source: + +1. :doc:`Install IfcOpenShell ` +2. `Clone the IfcOpenShell repository `_. +3. ``cd /path/to/IfcOpenShell/src/ifcmcp`` +4. ``pip install '.[mcp]'`` + +Setup +----- + +Add the server to your MCP client. For Claude Code:: + + claude mcp add --transport stdio ifc -- ifcmcp + +Or add to ``.mcp.json``: + +.. code-block:: json + + { + "mcpServers": { + "ifc": { + "type": "stdio", + "command": "ifcmcp" + } + } + } + +Available tools +--------------- + +**Session tools** + +- ``ifc_new(schema="IFC4")`` — create a new empty model in memory +- ``ifc_load(path)`` — open an IFC file into memory +- ``ifc_reset()`` — unload the current model, freeing all session state +- ``ifc_save(path="")`` — write model to disk; empty path overwrites the original + +**Query tools** + +- ``ifc_summary()`` — schema version, entity counts, project metadata +- ``ifc_tree()`` — full spatial hierarchy +- ``ifc_info(element_id)`` — deep inspection by step ID +- ``ifc_select(query)`` — filter elements by IFC class +- ``ifc_relations(element_id, traverse="")`` — relationships for an element +- ``ifc_clash(element_id, ...)`` — geometric intersection and clearance checks +- ``ifc_validate(express_rules=False)`` — schema and constraint validation +- ``ifc_schedule(max_depth=None)`` — work schedules with nested task trees +- ``ifc_cost(max_depth=None)`` — cost schedules with nested cost item trees +- ``ifc_schema(entity_type)`` — IFC class documentation +- ``ifc_contexts()`` — geometric representation contexts +- ``ifc_materials()`` — material definitions + +**Drawing and rendering tools** + +- ``ifc_plot(...)`` — generate a 2D drawing via ``ifcopenshell.draw`` and return it as an inline image the AI assistant can inspect; SVG always available, PNG requires ``cairosvg`` +- ``ifc_render(...)`` — off-screen 3D render returned as an inline PNG image the AI assistant can inspect; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings + +**ShapeBuilder tools** + +- ``ifc_shape_list()`` — list all available ``ShapeBuilder`` methods +- ``ifc_shape_docs(method)`` — documentation for a specific ``ShapeBuilder`` method +- ``ifc_shape(method, params="{}")`` — execute a ``ShapeBuilder`` method; entity references resolved by step ID + +**Edit tools** + +- ``ifc_list(module="")`` — list API modules or functions +- ``ifc_docs(function_path)`` — documentation for an API function +- ``ifc_edit(function_path, params="{}")`` — execute an ``ifcopenshell.api`` mutation +- ``ifc_quantify(rule, selector="")`` — run quantity take-off; writes ``IfcElementQuantity`` psets in-place + +Typical workflow +---------------- + +.. code-block:: text + + ifc_load("/path/to/model.ifc") + ifc_summary() + ifc_tree() + ifc_info(42) + ifc_edit("root.remove_product", '{"product": "42"}') + ifc_save() + +.. seealso:: + + :doc:`IfcQuery ` and :doc:`IfcEdit ` provide the same + functionality as standalone CLI tools for scripting and automation. diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst index 817630ad7d..3aa34a3aa8 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst @@ -775,3 +775,52 @@ responsibility to make sure the geometry is correct. # Assign our new body geometry back to our beam ifcopenshell.api.geometry.assign_representation(model, product=beam, representation=representation) + +Moving assemblies +----------------- + +When moving an assembly and you want all children to follow, pass +``should_transform_children=True``. The default (``False``) rewrites each +child's local placement to preserve its world position, so the parent moves +but the children stay where they are. + +.. code-block:: python + + matrix = numpy.eye(4) + matrix[:,3][0:3] = (0, 0, 6) + + # Move the assembly; children travel with it. + ifcopenshell.api.geometry.edit_object_placement(model, + product=assembly, matrix=matrix, is_si=True, + should_transform_children=True) + +Clipping normals convention +--------------------------- + +The ``normal`` passed to :func:`geometry.clip_solid`, +:func:`geometry.clip_solid_bounded`, and the ``clippings`` parameter of +:func:`geometry.add_wall_representation` points toward the **removed** +material (the discarded side), not toward the kept material. + +.. code-block:: python + + # Clip the top of a wall to a lean-to slope. + # normal points upward into the wedge that will be removed. + bcr = ifcopenshell.api.geometry.clip_solid(model, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908]) + shape_representation.RepresentationType = "Clipping" + +Opening lifecycle +----------------- + +``feature.remove_feature`` permanently deletes the feature entity from the +model. Any fillings (windows, doors) that occupied the opening become +orphaned and must be separately removed via ``root.remove_product``. + +.. code-block:: python + + # Remove a window and its opening from a wall. + ifcopenshell.api.root.remove_product(model, product=window) + ifcopenshell.api.feature.remove_feature(model, feature=opening) diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst index 4e28e83ed4..5ae16c5ec0 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst @@ -257,7 +257,8 @@ nest formulas, for example ``concat(title("foo"), lower("Bar"))`` will produce "``join({{separator}}, {{values}})``", "``join(""-"", {{mats.Name}})``", "``Name1-Name2``", "Joins a list of items with a custom separator. By default, all lists a rendered as comma separated." "``{{value1}}[+-*/]{{value2}}``", "``{{z}}+3``", "``5``", "Does arithmetic. Typical operators such as +, -, \*, and / are allowed and can be mixed with other variables and formatting functions." -When using queries in an IfcAnnotation tag surround with backticks. -Examples: -````number({{Qto_WallBaseQuantities.Width}}, ",",".")```` or -````round({{Qto_BuildingElementProxyQuantities.NetVolume}},.1)```` +When using queries in an IfcAnnotation tag surround with backticks. Examples: + +- ````number({{Qto_WallBaseQuantities.Width}}, ",",".")```` +- ````round({{Qto_BuildingElementProxyQuantities.NetVolume}},.1)```` +- ````join(", OVER ", reverse({{material.item.Material.Name}}))```` diff --git a/src/ifcopenshell-python/docs/ifcquery.rst b/src/ifcopenshell-python/docs/ifcquery.rst new file mode 100644 index 0000000000..8735b63da2 --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcquery.rst @@ -0,0 +1,102 @@ +.. This file was generated with the assistance of an AI coding tool. + +IfcQuery +======== + +IfcQuery is a CLI tool for querying and inspecting IFC building models. It +provides read-only subcommands for common inspection tasks, all outputting JSON +so results can be piped into other tools. + +Subcommands: + +- **summary** — schema version, entity counts, project metadata +- **tree** — full spatial hierarchy (IfcProject → Site → Building → Storeys → Spaces → Elements) +- **info** — deep inspection of any entity by step ID (attributes, property sets, placement matrix, type, material) +- **select** — filter elements by IFC class using the IfcOpenShell selector syntax +- **relations** — relationships for an element; use ``--traverse up`` to walk the hierarchy to IfcProject +- **clash** — geometric intersection and clearance checks; requires the IfcOpenShell C++ geometry bindings +- **validate** — schema and constraint validation; add ``--rules`` for a full EXPRESS check +- **schedule** — work schedules with nested task trees +- **cost** — cost schedules with nested cost item trees +- **schema** — IFC class documentation using the loaded model's schema version +- **contexts** — geometric representation contexts +- **materials** — material definitions (IfcMaterial, layer sets, constituent sets, profile sets) +- **plot** — generate a drawing (SVG or PNG) using ``ifcopenshell.draw``; PNG output requires ``cairosvg`` +- **render** — off-screen 3D render to a PNG image; requires ``pyvista`` and the IfcOpenShell C++ geometry bindings + +All subcommands accept ``--format json|text|ids`` to control output (default: ``json``): + +- ``json`` — structured JSON, suitable for piping to ``jq`` or ``ifcedit foreach`` +- ``text`` — indented human-readable output +- ``ids`` — comma-separated step IDs extracted from list results, suitable for piping directly into ``ifcedit run`` parameters + +Installation +------------ + +:: + + pip install ifcquery + +For PNG output from ``plot``, also install ``cairosvg``:: + + pip install cairosvg + +For 3D rendering with ``render``, also install ``pyvista``:: + + pip install pyvista + +Or install from source: + +1. :doc:`Install IfcOpenShell ` +2. `Clone the IfcOpenShell repository `_. +3. ``cd /path/to/IfcOpenShell/src/ifcquery`` +4. ``pip install .`` + +Usage +----- + +:: + + $ ifcquery model.ifc summary + $ ifcquery model.ifc tree + $ ifcquery model.ifc info 42 + $ ifcquery model.ifc select 'IfcWall' + $ ifcquery model.ifc relations 42 + $ ifcquery model.ifc relations 42 --traverse up + $ ifcquery model.ifc validate + $ ifcquery model.ifc validate --rules + $ ifcquery model.ifc schedule + $ ifcquery model.ifc cost + $ ifcquery model.ifc schema IfcWall + $ ifcquery model.ifc materials + $ ifcquery model.ifc plot -o floorplan.svg --out-format svg --view floorplan + $ ifcquery model.ifc plot -o floorplan.png --view floorplan + $ ifcquery model.ifc render -o model.png + $ ifcquery model.ifc --format ids select 'IfcWall' + +Scripting with ifcedit +---------------------- + +``ifcquery`` and ``ifcedit`` are designed to compose. Use ``--format ids`` to +pass query results directly into ``ifcedit run`` parameters, or pipe JSON into +``ifcedit foreach`` to apply an operation to every matching element:: + + # Aggregate — pass all IDs as a list parameter + $ ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + + # Fan-out — one operation per element, model opened and saved once + $ ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + + # Render an element highlighted against everything related to it + $ ifcquery model.ifc render -o relations.png \ + --element "$(ifcquery model.ifc --format ids relations 42)" + + # Render a clash — subject and clashing elements highlighted together + $ ifcquery model.ifc render -o clash.png \ + --element "$(ifcquery model.ifc --format ids clash 42)" + +.. seealso:: + + Use :doc:`IfcEdit ` to make mutations to IFC files from the command + line, and :doc:`IfcMCP ` for interactive AI-assisted editing. diff --git a/src/ifcopenshell-python/docs/index.rst b/src/ifcopenshell-python/docs/index.rst index 8d241bbf74..895dbece88 100644 --- a/src/ifcopenshell-python/docs/index.rst +++ b/src/ifcopenshell-python/docs/index.rst @@ -30,9 +30,12 @@ Let's learn IfcOpenShell! ifcclash ifccsv ifcdiff + ifcedit ifcfm ifcmax + ifcmcp ifcpatch + ifcquery ifcsverchok ifctester other diff --git a/src/ifcopenshell-python/docs/introduction.rst b/src/ifcopenshell-python/docs/introduction.rst index 58430b6da8..39b963e8f7 100644 --- a/src/ifcopenshell-python/docs/introduction.rst +++ b/src/ifcopenshell-python/docs/introduction.rst @@ -69,9 +69,12 @@ IfcOpenShell is a modular ecosystem of tools that work together, where each tool "`IfcClash `_", "A CLI utility and library that lets you perform clash detection on one or more IFC models. Clashes are defined in terms of clash sets with filters using the IFC query syntax." "`IfcCSV `_", "View and edit IFC data using spreadsheets or tabular datasets, such as CSV, ODS, XLSX, Pandas DataFrames, and regular Python lists." "`IfcDiff `_", "A CLI utility and library that lets you compare the changes between two IFC models." + "`IfcEdit `_", "A CLI wrapper for all ifcopenshell.api mutation functions. Browse available API modules, read per-function documentation, and run any API function against an IFC file from the command line." "`IfcFM `_", "A highly standards-compliant tool (e.g. COBie 2.4, COBie 3.0, AOH-BSEM) to convert FM data in IFC databases to spreadsheets and other machine readable formats, such as ODS, XLSX, CSV, Pandas, XML, and JSON." "`IfcMax `_", "A 3ds Max importer plugin able to import the IFC file format." + "`IfcMCP `_", "An MCP (Model Context Protocol) server that exposes IfcOpenShell query and edit tools to AI coding assistants. Loads a model into memory and keeps it there across tool calls, so no file I/O is needed between operations." "`IfcPatch `_", "A CLI utility and library that lets you run and distribute predetermined modifications on an IFC file, known as a patch recipe. Useful in deploying a data pipeline or batch-fixing external models." + "`IfcQuery `_", "A CLI tool for querying and inspecting IFC building models. Subcommands cover spatial hierarchy, element inspection, relationship traversal, clash detection, schema documentation, work schedules, and cost schedules." "`IfcSverchok `_", "A node based visual programming add-on for Blender to interact with IFC and Sverchok." "`IfcTester `_", "Author and read Information Delivery Specification (IDS) files. You can validate IFC models against IDS and generate reports in multiple formats. It works from the command line, as a web app, or as a library." "`VoxelisationToolkit `_", "Converts .ifc geometry into voxels, and lets you perform voxel based geometric analysis." diff --git a/src/ifcopenshell-python/ifcopenshell/__init__.py b/src/ifcopenshell-python/ifcopenshell/__init__.py index 998eb6e5de..7d247c6fab 100644 --- a/src/ifcopenshell-python/ifcopenshell/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/__init__.py @@ -111,8 +111,8 @@ __all__ = [ ] try: - from .stream import stream, stream_entity - from .stream import stream as _stream + from .stream import stream, stream_entity # ty: ignore[possibly-missing-import] + from .stream import stream as _stream # ty: ignore[possibly-missing-import] except: pass @@ -131,17 +131,21 @@ class SchemaError(Error): @overload def open( - path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[False] = False + path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[False] = False ) -> Union[_file, sqlite]: ... @overload -def open(path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: Literal[True]) -> _stream: ... +def open(path: Union[os.PathLike, str], format: SupportedFormat = None, *, should_stream: Literal[True]) -> _stream: ... @overload def open( - path: Union[os.PathLike, str], format: Optional[str] = None, *, should_stream: bool = False, readonly: bool = False + path: Union[os.PathLike, str], + format: SupportedFormat = None, + *, + should_stream: bool = False, + readonly: bool = False, ) -> Union[_file, sqlite, _stream]: ... def open( path: Union[os.PathLike, str], - format: Optional[str] = None, + format: SupportedFormat = None, should_stream: bool = False, readonly: bool = False, mmap: bool = False, @@ -153,8 +157,7 @@ def open( for reading large files. You can specify a file format. If no format is given, it is guessed from - its extension. Currently supported specified format: .ifc | .ifcZIP | - .ifcXML. + its extension. You can then filter by element ID, class, etc, and subscript by id or guid. @@ -199,11 +202,13 @@ def open( for ty in bypass_types: f.bypass_type(ty) if mmap: - f.initialize(str(path.absolute()), mmap=mmap) + # mmap parameter is only available for builds with USE_MMAP, not used in our main builds + f.initialize(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument] else: f.initialize(str(path.absolute())) elif mmap: - f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) + # mmap parameter is only available for builds with USE_MMAP, not used in our main builds + f = ifcopenshell_wrapper.open(str(path.absolute()), mmap=mmap) # ty: ignore[unknown-argument] else: f = ifcopenshell_wrapper.open(str(path.absolute())) return file(f) @@ -286,7 +291,10 @@ def schema_by_name( return ifcopenshell_wrapper.schema_by_name(schema) -def guess_format(path: Path) -> Literal[".ifc", ".ifcZIP", ".ifcXML", ".ifcJSON", ".ifcSQLite", None]: +SupportedFormat = Literal[".ifc", ".ifcZIP", ".ifcXML", ".ifcJSON", ".ifcSQLite", "rocksdb", None] + + +def guess_format(path: Path) -> SupportedFormat: """Guesses the IFC format using file extension IFCs may be serialised as different formats. The most common is a ``.ifc`` diff --git a/src/ifcopenshell-python/ifcopenshell/api/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/__init__.py index 49c3b471dd..f485e865fb 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/__init__.py @@ -42,7 +42,6 @@ import importlib import inspect import json from collections.abc import Callable -from functools import partial from typing import TYPE_CHECKING, Any, Optional import numpy @@ -90,11 +89,7 @@ def renamed_arguments_deprecation( # "group.add_group": partial( # renamed_arguments_deprecation, arguments_remapped={"Name": "name", "Description": "description"} # ), -ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = { - "control.assign_control": partial( - batching_argument_deprecation, prev_argument="related_object", new_argument="related_objects" - ), -} +ARGUMENTS_DEPRECATION: dict[str, Callable[[str, dict[str, Any]], tuple[str, dict[str, Any]]]] = {} CACHED_USECASE_CLASSES: dict[str, Callable] = {} diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py index f974ea2cd2..9d988a4e82 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/assign_connection_geometry.py @@ -108,7 +108,7 @@ class Usecase: self.rel_space_boundary.ConnectionGeometry = connection_geometry def create_point(self, point: npt.NDArray) -> ifcopenshell.entity_instance: - return self.file.create_enitty("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale)) + return self.file.create_entity("IfcCartesianPoint", ifc_safe_vector_type(point / self.unit_scale)) def close_polyline( self, points: tuple[ifcopenshell.entity_instance, ...] @@ -127,7 +127,7 @@ class Usecase: return self.file.createIfcPlane( self.file.createIfcAxis2Placement3D( self.create_point(location), - self.file.createIfcDirection(axis), - self.file.createIfcDirection(ref_direction), + self.file.createIfcDirection(ifc_safe_vector_type(axis)), + self.file.createIfcDirection(ifc_safe_vector_type(ref_direction)), ) ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py index ee11094d13..4fa5516bc6 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py +++ b/src/ifcopenshell-python/ifcopenshell/api/boundary/edit_attributes.py @@ -27,12 +27,11 @@ def edit_attributes( related_building_element: ifcopenshell.entity_instance, parent_boundary: Optional[ifcopenshell.entity_instance] = None, corresponding_boundary: Optional[ifcopenshell.entity_instance] = None, + physical_or_virtual: str = "NOTDEFINED", + internal_or_external: str = "NOTDEFINED", ) -> None: """Modify the relationships of a space boundary relationship - Currently this function is quite minimal and offers no advantage to - manual assignment of the space boundary attributes. - :param entity: The IfcRelSpaceBoundary to modify :param relating_space: The IfcSpace or IfcExternalSpatialElement that the space boundary is related to. @@ -44,17 +43,18 @@ def edit_attributes( :param corresponding_boundary: The other IfcRelSpaceBoundary on the other side of the related element. The pair together represents a thermal boundary. This only applies to 2nd level boundaries. + :param physical_or_virtual: IfcPhysicalOrVirtualEnum value: "PHYSICAL", + "VIRTUAL", or "NOTDEFINED". + :param internal_or_external: IfcInternalOrExternalEnum value: + "INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", + "EXTERNAL_FIRE", or "NOTDEFINED". :return: None """ - entity = entity - relating_space = relating_space - related_building_element = related_building_element - parent_boundary = parent_boundary - corresponding_boundary = corresponding_boundary - entity.RelatingSpace = relating_space entity.RelatedBuildingElement = related_building_element if hasattr(entity, "ParentBoundary"): entity.ParentBoundary = parent_boundary if hasattr(entity, "CorrespondingBoundary"): entity.CorrespondingBoundary = corresponding_boundary + entity.PhysicalOrVirtualBoundary = physical_or_virtual + entity.InternalOrExternalBoundary = internal_or_external diff --git a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py index bb4dcff79a..806782b1e4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py +++ b/src/ifcopenshell-python/ifcopenshell/api/context/remove_context.py @@ -51,8 +51,10 @@ def remove_context(file: ifcopenshell.file, context: ifcopenshell.entity_instanc new = context.ParentContext for inverse in file.get_inverse(context): if inverse.is_a("IfcCoordinateOperation"): + # Trick to make sure the coordinate operation is not referenced + # by a context so we can delete it safely inverse.SourceCRS = inverse.TargetCRS - ifcopenshell.util.element.remove_deep(file, inverse) + ifcopenshell.util.element.remove_deep2(file, inverse) else: ifcopenshell.util.element.replace_attribute(inverse, context, new) file.remove(context) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py index e1be553922..3a9bbe0cde 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/edit_cost_value.py @@ -59,6 +59,6 @@ def edit_cost_value( value["ValueComponent"], ) value = file.create_entity("IfcMeasureWithUnit", value_component, value["UnitComponent"]) - if old_unit_basis and file.get_total_inverses(old_unit_basis) == 0: - ifcopenshell.util.element.remove_deep(file, old_unit_basis) + if old_unit_basis: + ifcopenshell.util.element.remove_deep2(file, old_unit_basis) setattr(cost_value, name, value) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py index ce1aa5545e..9b90cce2e8 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_item.py @@ -51,7 +51,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins if history: ifcopenshell.util.element.remove_deep2(file, history) elif inverse.is_a("IfcRelAssignsToControl"): - if len(inverse.RelatedObjects) >= 2 or inverse.RelatingControl == cost_item: + if len(inverse.RelatedObjects) >= 2: continue history = inverse.OwnerHistory file.remove(inverse) diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py index 012dce92f6..d24e45cbea 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/__init__.py @@ -25,12 +25,24 @@ annotations may have relationships which indicate smart data being populated. from .. import wrap_usecases from .assign_product import assign_product from .edit_text_literal import edit_text_literal +from .regenerate_dimension import regenerate_dimension, get_dimension_segment_lengths +from .resolve_anchor import build_anchor_from_hit, build_anchor_from_layer_boundary, build_anchor_from_profile_vert, build_anchor_from_profile_edge, get_layer_snap_candidates, get_profile_snap_candidates, make_world_anchor, resolve_anchor from .unassign_product import unassign_product wrap_usecases(__path__, __name__) __all__ = [ "assign_product", + "build_anchor_from_hit", + "build_anchor_from_layer_boundary", + "build_anchor_from_profile_edge", + "build_anchor_from_profile_vert", "edit_text_literal", + "get_dimension_segment_lengths", + "get_layer_snap_candidates", + "get_profile_snap_candidates", + "make_world_anchor", + "regenerate_dimension", + "resolve_anchor", "unassign_product", ] diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py new file mode 100644 index 0000000000..313e430a5c --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/regenerate_dimension.py @@ -0,0 +1,383 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +"""Regenerate a parametric dimension annotation from its BBIM_Dimension anchors. + +This module operates purely on IFC data. It: + 1. Reads the ``Anchors`` JSON array from the ``BBIM_Dimension`` pset on an + ``IfcAnnotation``. + 2. Resolves each anchor to a world-space point (IFC project units) using + ``resolve_anchor``. + 3. Computes per-segment distances and updates (or creates) the linked + ``IfcMetric`` + ``IfcRelAssociatesConstraint`` entities. + 4. Returns the ordered list of resolved world-space points so that the + Bonsai operator layer can update the Blender curve object. + +Updating the Blender curve (converting IFC world coords → annotation local +coords) is the *caller's* responsibility and does **not** happen here. +""" + +from __future__ import annotations + +import json +import math +from typing import Optional + +import ifcopenshell +import ifcopenshell.api.owner +import ifcopenshell.api.pset +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.util.element + +from .resolve_anchor import resolve_anchor + + +_PSET_NAME = "BBIM_Dimension" +_METRIC_INTENT_PREFIX = "PARAMETRIC_DIMENSION_SEG_" + + +def regenerate_dimension( + file: ifcopenshell.file, + annotation: ifcopenshell.entity_instance, + settings: Optional[ifcopenshell.geom.settings] = None, + shape_cache: Optional[dict] = None, + placement_override: Optional[dict] = None, +) -> list[tuple[float, float, float]]: + """Regenerate a parametric dimension from its stored anchor references. + + Resolves every anchor in ``BBIM_Dimension.Anchors``, updates the + per-segment ``IfcMetric`` values (creating them when absent), and returns + the resolved world-space points in metres. + + :param file: The open IFC file. + :param annotation: An ``IfcAnnotation`` with a ``BBIM_Dimension`` pset. + :param settings: Geometry settings for tessellation (shared across calls). + :param shape_cache: Shape cache dict (shared across calls for performance). + :param placement_override: Optional dict mapping element STEP id → 4Ɨ4 numpy + matrix (metres, row-major). Pass ``{elem.id(): np.array(obj.matrix_world)}`` + for each referenced element so that viewport moves not yet synced to the + IFC ``ObjectPlacement`` are reflected. See ``resolve_anchor`` for details. + :return: Ordered list of ``(x, y, z)`` tuples, one per anchor. + Empty list if the pset is missing or malformed. + """ + pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME) + if not pset_data or "Anchors" not in pset_data: + return [] + + try: + anchors: list[dict] = json.loads(pset_data["Anchors"]) + except (json.JSONDecodeError, TypeError): + return [] + + if not anchors: + return [] + + if shape_cache is None: + shape_cache = {} + + resolved: list[Optional[tuple]] = [] + for anchor in anchors: + pt = resolve_anchor(file, anchor, settings, shape_cache, placement_override) + if pt is None: + pt = tuple(anchor["pt"]) if anchor.get("pt") else (0.0, 0.0, 0.0) + resolved.append(pt) + anchor["pt"] = list(pt) + + # ForcePerpendicularToFace: project vertices 1…n onto the line through + # pt[0] in the direction of anchor[0]'s face normal, so the polyline is + # constrained perpendicular to the face the first vertex is anchored to. + if pset_data.get("ForcePerpendicularToFace") and len(resolved) >= 2 and resolved[0] is not None: + normal = _get_anchor_face_normal_world(file, anchors[0], placement_override) + if normal: + base = resolved[0] + for i in range(1, len(resolved)): + if resolved[i] is None: + continue + pt = resolved[i] + t = ((pt[0] - base[0]) * normal[0] + + (pt[1] - base[1]) * normal[1] + + (pt[2] - base[2]) * normal[2]) + resolved[i] = (base[0] + t * normal[0], + base[1] + t * normal[1], + base[2] + t * normal[2]) + anchors[i]["pt"] = list(resolved[i]) + + pset_entity_id = pset_data.get("id") + if pset_entity_id: + pset_entity = file.by_id(pset_entity_id) + ifcopenshell.api.pset.edit_pset( + file, + pset=pset_entity, + properties={"Anchors": json.dumps(anchors)}, + ) + + n_segments = len(resolved) - 1 + if n_segments >= 1: + existing_metrics = _get_segment_metrics(file, annotation) + _sync_segment_metrics(file, annotation, resolved, existing_metrics) + + # LinePosition: project all points to a fixed absolute world coordinate along the + # horizontal offset axis (perpendicular to the dimension direction). Applied after + # the pset write so anchor["pt"] always stores the true geometry surface hit. + # Because it is absolute, the dimension line stays put even if the geometry moves. + # Only active when ForcePerpendicularToFace is also set — the two are semantically coupled. + line_position = pset_data.get("LinePosition") + if line_position is not None and pset_data.get("ForcePerpendicularToFace") and resolved: + face_normal = _get_anchor_face_normal_world(file, anchors[0], placement_override) + offset_dir = _get_line_offset_direction(face_normal, [pt for pt in resolved if pt is not None]) + if offset_dir: + resolved = [ + _project_to_line_position(pt, offset_dir, float(line_position)) if pt is not None else None + for pt in resolved + ] + + return [pt for pt in resolved if pt is not None] + + +def get_dimension_segment_lengths( + file: ifcopenshell.file, + annotation: ifcopenshell.entity_instance, +) -> list[float]: + """Return the segment lengths for a parametric dimension from stored anchor pts. + + Distances are computed from the cached ``pt`` fields in ``BBIM_Dimension.Anchors`` + (in metres, matching ifcopenshell.geom output). Returns an empty list if the pset + is absent or malformed. + """ + pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME) + if not pset_data or not pset_data.get("Anchors"): + return [] + try: + anchors: list[dict] = json.loads(pset_data["Anchors"]) + except Exception: + return [] + lengths: list[float] = [] + for i in range(len(anchors) - 1): + pt_a = anchors[i].get("pt") + pt_b = anchors[i + 1].get("pt") + if pt_a and pt_b: + lengths.append(_dist(tuple(pt_a), tuple(pt_b))) + else: + lengths.append(0.0) + return lengths + + +# --------------------------------------------------------------------------- +# IfcMetric / IfcRelAssociatesConstraint management +# --------------------------------------------------------------------------- + + +def _get_segment_metrics( + file: ifcopenshell.file, + annotation: ifcopenshell.entity_instance, +) -> dict[int, ifcopenshell.entity_instance]: + """Return {segment_index: IfcMetric} for all constraint rels on the annotation.""" + metrics: dict[int, ifcopenshell.entity_instance] = {} + for rel in annotation.HasAssociations: + if not rel.is_a("IfcRelAssociatesConstraint"): + continue + intent: str = rel.Intent or "" + if not intent.startswith(_METRIC_INTENT_PREFIX): + continue + try: + seg_idx = int(intent[len(_METRIC_INTENT_PREFIX):]) + except ValueError: + continue + constraint = rel.RelatingConstraint + if constraint.is_a("IfcMetric"): + metrics[seg_idx] = constraint + return metrics + + +def _sync_segment_metrics( + file: ifcopenshell.file, + annotation: ifcopenshell.entity_instance, + resolved_pts: list[tuple], + existing: dict[int, ifcopenshell.entity_instance], +) -> None: + """Create missing and update existing IfcMetric entities for each segment.""" + n_segments = len(resolved_pts) - 1 + seen_guids: set[str] = set() + + # Build a lookup of which elements are at each anchor endpoint + pset_data = ifcopenshell.util.element.get_pset(annotation, _PSET_NAME) + anchors: list[dict] = [] + if pset_data and pset_data.get("Anchors"): + try: + anchors = json.loads(pset_data["Anchors"]) + except Exception: + pass + + for seg_idx in range(n_segments): + if seg_idx in existing: + pass # metric already exists; association is still valid + else: + # Create new IfcMetric + IfcRelAssociatesConstraint + # DataValue is IfcMetricValueSelect (entity-only SELECT in IFC4) — omit it; + # the measured distance is derivable from the anchor pt fields. + metric = file.create_entity( + "IfcMetric", + Name=f"seg_{seg_idx}", + ConstraintGrade="ADVISORY", + Benchmark="EQUALTO", + ) + # Gather related products for this segment (the two anchor elements) + related: list[ifcopenshell.entity_instance] = [annotation] + for anchor_idx in (seg_idx, seg_idx + 1): + if anchor_idx < len(anchors): + guid = anchors[anchor_idx].get("guid") + if guid and guid not in seen_guids: + try: + elem = file.by_guid(guid) + related.append(elem) + seen_guids.add(guid) + except Exception: + pass + + file.create_entity( + "IfcRelAssociatesConstraint", + GlobalId=ifcopenshell.guid.new(), + OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), + Intent=f"{_METRIC_INTENT_PREFIX}{seg_idx}", + RelatingConstraint=metric, + RelatedObjects=related, + ) + + # Remove orphaned metrics for segments that no longer exist + for seg_idx, metric in existing.items(): + if seg_idx >= n_segments: + for rel in file.get_inverse(metric): + if rel.is_a("IfcRelAssociatesConstraint"): + file.remove(rel) + file.remove(metric) + + +def _dist(a: tuple, b: tuple) -> float: + return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) + + +def _project_to_line_position( + pt: tuple, offset_dir: tuple, target: float +) -> tuple[float, float, float]: + """Shift *pt* along *offset_dir* so its projection onto that axis equals *target*. + + Keeps every other component of the point unchanged, so only the dimension line + is repositioned — the measured length stays the same. + """ + current = pt[0] * offset_dir[0] + pt[1] * offset_dir[1] + pt[2] * offset_dir[2] + delta = target - current + return ( + pt[0] + delta * offset_dir[0], + pt[1] + delta * offset_dir[1], + pt[2] + delta * offset_dir[2], + ) + + +def _get_anchor_face_normal_world( + file: ifcopenshell.file, + anchor: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Return the world-space unit face normal stored in a FACE anchor, or None. + + Reads ``normal_local`` (element-local, rotation-invariant) from the anchor + addr and rotates it to world space via the current element placement. + Also accepts the legacy ``addr.fingerprint.normal_local`` format. + """ + if anchor.get("type") != "FACE": + return None + guid = anchor.get("guid") + if not guid: + return None + try: + element = file.by_guid(guid) + except Exception: + return None + + addr = anchor.get("addr") or {} + from .resolve_anchor import _rotate_local_to_world + + if addr.get("method") == "LAYER_BOUNDARY": + import ifcopenshell.util.element as _ifc_elem + usage = _ifc_elem.get_material(element, should_inherit=True) + if not usage or not usage.is_a("IfcMaterialLayerSetUsage"): + return None + axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + if axis == "AXIS1": + normal_local: tuple = (1.0, 0.0, 0.0) + elif axis == "AXIS3": + normal_local = (0.0, 0.0, 1.0) + else: + normal_local = (0.0, 1.0, 0.0) + else: + # FACE_NORMAL: normal_local stored in addr (new) or addr.fingerprint (legacy). + normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local") + if not normal_local: + return None + + n = _rotate_local_to_world(element, normal_local, placement_override) + mag = math.sqrt(n[0] ** 2 + n[1] ** 2 + n[2] ** 2) + return (n[0] / mag, n[1] / mag, n[2] / mag) if mag > 1e-12 else None + + +def _get_line_offset_direction( + face_normal: Optional[tuple[float, float, float]], + resolved_pts: list[tuple], +) -> Optional[tuple[float, float, float]]: + """Return the direction to apply LineOffset — parallel to the first face. + + Uses cross(world_Z, dim_direction) to get the horizontal direction + perpendicular to the dimension line, which slides the line sideways + (parallel to the face) rather than into/out of it. + + Falls back to cross(face_normal, world_Z) when the dimension line is + nearly vertical (e.g. elevation dimensions). + """ + world_z = (0.0, 0.0, 1.0) + + # Primary: use the dimension line direction (anchor[0] → anchor[1]) + if len(resolved_pts) >= 2: + a, b = resolved_pts[0], resolved_pts[1] + dx, dy, dz = b[0] - a[0], b[1] - a[1], b[2] - a[2] + dim_mag = math.sqrt(dx * dx + dy * dy + dz * dz) + if dim_mag > 1e-10: + dim_dir = (dx / dim_mag, dy / dim_mag, dz / dim_mag) + # cross(world_Z, dim_dir) — horizontal direction perp to dimension + d = ( + world_z[1] * dim_dir[2] - world_z[2] * dim_dir[1], + world_z[2] * dim_dir[0] - world_z[0] * dim_dir[2], + world_z[0] * dim_dir[1] - world_z[1] * dim_dir[0], + ) + mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) + if mag > 1e-6: + return (d[0] / mag, d[1] / mag, d[2] / mag) + + # Fallback for vertical dims: cross(face_normal, world_Z) + if face_normal: + n = face_normal + d = ( + n[1] * world_z[2] - n[2] * world_z[1], + n[2] * world_z[0] - n[0] * world_z[2], + n[0] * world_z[1] - n[1] * world_z[0], + ) + mag = math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) + if mag > 1e-6: + return (d[0] / mag, d[1] / mag, d[2] / mag) + + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py new file mode 100644 index 0000000000..25bcec5b65 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/drawing/resolve_anchor.py @@ -0,0 +1,1182 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +"""Resolve a parametric dimension anchor to a world-space coordinate in metres. + +NOTE ON COORDINATE SPACE +All anchor coordinates (``pt``, ``hint``) are stored in metres (= Blender world +space). Profile vertex positions computed from IFC attributes are converted to +metres via ``ifcopenshell.util.unit.calculate_unit_scale``. + +Anchor schema (JSON-serialisable dict stored in BBIM_Dimension.Anchors): + + { + "guid": str | None, # element GlobalId; None → WORLD type (free point) + "type": str, # "FACE" | "EDGE" | "VERTEX" | "WORLD" + "addr": { + # FACE_NORMAL — face identified by element-local unit normal (platform-agnostic). + # Rotation-invariant: moving/rotating the element does not invalidate the anchor. + "method": "FACE_NORMAL", + "normal_local": [float, float, float], # element-local unit normal + + # PROFILE_LOCAL — point in IfcExtrudedAreaSolid profile-local space. + # profile_x_m / profile_y_m are in the profile's 2-D local frame, metres. + # extrusion_z_m is distance along the normalized ExtrudedDirection, metres. + # snap: "VERTEX" → re-snap to nearest profile vertex on resolution, + # "EDGE" → use coords directly (midpoint between two vertices). + "method": "PROFILE_LOCAL", + "profile_x_m": float, + "profile_y_m": float, + "extrusion_z_m": float, + "snap": "VERTEX" | "EDGE", + + # LAYER_BOUNDARY — face/boundary of a material layer (platform-agnostic). + # Resolution tiers (first match wins): + # layer_id → IfcMaterialLayer STEP id (stable in NativeBIM) + # layer_material_id → IfcMaterial STEP id + # layer_material_name → IfcMaterial.Name + # layer_category → IfcMaterialLayer.Category (IFC4) + # layer_index → 0-based position in layer set + # offset_from_ref_m → proximity to nearest boundary (geometric fallback) + "method": "LAYER_BOUNDARY", + "layer_id": int, + "layer_material_id": int, + "layer_material_name": str, + "layer_category": str, + "layer_index": int, + "face": "start" | "end", + "offset_from_ref_m": float, + } | None, + "hint": [x, y, z] | None, + "pt": [x, y, z] # cached world position; universal static fallback + } +""" + +from __future__ import annotations + +import math +from typing import Optional + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.placement +import ifcopenshell.util.unit + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def resolve_anchor( + file: ifcopenshell.file, + anchor: dict, + settings: Optional[ifcopenshell.geom.settings] = None, + shape_cache: Optional[dict] = None, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve an anchor dict to a world-space point in metres. + + Resolution order: + 1. WORLD / null guid → return stored ``pt`` directly. + 2. PROFILE_LOCAL → analytically resolve via IfcExtrudedAreaSolid profile coords. + 3. PROFILE_VERT / PROFILE_EDGE → legacy index-based resolution (backwards compat). + 4. FACE_NORMAL → match face group by element-local normal (rotation-invariant). + 5. Fallback → stored ``pt``. + + :param file: The open IFC file. + :param anchor: Anchor descriptor dict. + :param settings: ifcopenshell.geom settings; created automatically when None. + :param shape_cache: Mutable dict keyed by element STEP id to cache shapes. + :param placement_override: Optional dict mapping element STEP id → 4Ɨ4 numpy + matrix (row-major, metres). When provided, this matrix is used instead of + ``element.ObjectPlacement`` for the local→world transform. Pass the + Blender object's ``matrix_world`` here so that elements moved in the + viewport but not yet explicitly synced to IFC are handled correctly. + :return: ``(x, y, z)`` in metres, or ``None``. + """ + anchor_type = anchor.get("type", "WORLD") + guid = anchor.get("guid") + + if anchor_type == "WORLD" or not guid: + return _pt_or_none(anchor.get("pt")) + + try: + element = file.by_guid(guid) + except Exception: + return _pt_or_none(anchor.get("pt")) + + addr = anchor.get("addr") or {} + + if anchor_type in ("VERTEX", "EDGE"): + method = addr.get("method", "") + if method == "PROFILE_LOCAL": + pt = _resolve_profile_local_anchor(file, element, addr, placement_override) + elif method == "PROFILE_VERT": + pt = _resolve_profile_vert_anchor(file, element, addr, placement_override) + elif method == "PROFILE_EDGE": + pt = _resolve_profile_edge_anchor(file, element, addr, placement_override) + else: + pt = None + return pt if pt is not None else _pt_or_none(anchor.get("pt")) + + # FACE anchor — check method first; LAYER_BOUNDARY does not need tessellation. + method = addr.get("method", "FACE_NORMAL") + if method == "LAYER_BOUNDARY": + pt = _resolve_layer_boundary_anchor(file, element, addr, placement_override) + return pt if pt is not None else _pt_or_none(anchor.get("pt")) + + # FACE_NORMAL — match by element-local normal (rotation-invariant). + shape = _get_shape(file, element, settings, shape_cache) + if shape is None: + return _pt_or_none(anchor.get("pt")) + + verts, tris = _extract_mesh(shape) + if not tris: + return _pt_or_none(anchor.get("pt")) + + groups = _group_coplanar_tris(verts, tris) + group_props = [_face_group_props(g, verts, tris) for g in groups] + world_group_props = [ + { + "centroid": _local_to_world_m(file, element, gp["centroid"], placement_override), + "normal": _rotate_local_to_world(element, gp["normal"], placement_override), + "area": gp["area"], + } + for gp in group_props + ] + + # Support both new FACE_NORMAL (addr.normal_local) and legacy fingerprint field. + normal_local = addr.get("normal_local") or (addr.get("fingerprint") or {}).get("normal_local") + hint = anchor.get("hint") + pt = _find_by_local_normal(group_props, world_group_props, normal_local, hint) if normal_local else None + return pt if pt is not None else _pt_or_none(anchor.get("pt")) + + +def build_anchor_from_hit( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + hit_location_ifc: tuple[float, float, float], + hit_normal_ifc: tuple[float, float, float], + settings: Optional[ifcopenshell.geom.settings] = None, + shape_cache: Optional[dict] = None, + placement_override: Optional[dict] = None, +) -> dict: + """Build a FACE/FACE_NORMAL anchor dict from a viewport ray-cast hit. + + Tessellates the element, finds the best-matching face group, and stores the + element-local unit normal. The local normal is rotation-invariant: moving or + rotating the element does not invalidate the anchor. + + :param file: The open IFC file. + :param element: The IFC element that was hit. + :param hit_location_ifc: Hit point in metres (world space). + :param hit_normal_ifc: Face normal at the hit point (world space, unit vec). + :param settings: Geometry settings for tessellation. + :param shape_cache: Mutable shape-cache dict. + :param placement_override: Optional dict mapping element STEP id → 4Ɨ4 numpy + matrix (metres). See ``resolve_anchor`` for details. + :return: Anchor dict ready for JSON serialisation into BBIM_Dimension. + """ + shape = _get_shape(file, element, settings, shape_cache) + normal_local: list = list(hit_normal_ifc) # fallback: world normal as approximation + + if shape is not None: + verts, tris = _extract_mesh(shape) + groups = _group_coplanar_tris(verts, tris) + local_group_props = [_face_group_props(g, verts, tris) for g in groups] + world_group_props = [ + { + "centroid": _local_to_world_m(file, element, gp["centroid"], placement_override), + "normal": _rotate_local_to_world(element, gp["normal"], placement_override), + "area": gp["area"], + } + for gp in local_group_props + ] + best = _best_group(world_group_props, hit_normal_ifc, hit_location_ifc) + if best is not None: + best_idx, _ = best + normal_local = list(local_group_props[best_idx]["normal"]) + + return { + "guid": element.GlobalId, + "type": "FACE", + "addr": { + "method": "FACE_NORMAL", + "normal_local": normal_local, + }, + "hint": list(hit_location_ifc), + "pt": list(hit_location_ifc), + } + + +def make_world_anchor(pt_ifc: tuple[float, float, float]) -> dict: + """Build a free-floating (WORLD) anchor — not connected to any element.""" + return { + "guid": None, + "type": "WORLD", + "addr": None, + "hint": None, + "pt": list(pt_ifc), + } + + +def build_anchor_from_profile_vert( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build a VERTEX/PROFILE_LOCAL anchor from a profile snap candidate. + + :param snap: Dict from ``get_profile_snap_candidates`` with keys + ``snap_world``, ``profile_x_m``, ``profile_y_m``, ``extrusion_z_m``. + :return: Anchor dict ready for JSON serialisation. + """ + world_pos = snap["snap_world"] + return { + "guid": element.GlobalId, + "type": "VERTEX", + "addr": { + "method": "PROFILE_LOCAL", + "profile_x_m": snap["profile_x_m"], + "profile_y_m": snap["profile_y_m"], + "extrusion_z_m": snap["extrusion_z_m"], + "snap": "VERTEX", + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def build_anchor_from_profile_edge( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build an EDGE/PROFILE_LOCAL anchor from a profile snap candidate. + + :param snap: Dict from ``get_profile_snap_candidates`` with keys + ``snap_world``, ``profile_x_m``, ``profile_y_m``, ``extrusion_z_m``. + :return: Anchor dict ready for JSON serialisation. + """ + world_pos = snap["snap_world"] + return { + "guid": element.GlobalId, + "type": "EDGE", + "addr": { + "method": "PROFILE_LOCAL", + "profile_x_m": snap["profile_x_m"], + "profile_y_m": snap["profile_y_m"], + "extrusion_z_m": snap["extrusion_z_m"], + "snap": "EDGE", + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def get_layer_snap_candidates( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + placement_override: Optional[dict] = None, +) -> list[dict]: + """Return one snap candidate per IfcMaterialLayer boundary in the element. + + Each candidate dict has: + - ``type``: ``"VERTEX"`` + - ``snap_world``: mid-height world position on the boundary, metres + - ``v0``, ``v1``: base and top endpoints of the boundary line (for GPU draw) + - ``layer``: the IfcMaterialLayer entity + - ``layer_index``: 0-based index in the layer set + - ``face``: ``"start"`` or ``"end"`` + - ``offset_from_ref_m``: signed offset from element origin along thickness axis + + Returns [] when the element has no IfcMaterialLayerSetUsage or IfcExtrudedAreaSolid. + """ + import ifcopenshell.util.element as ifc_elem + + material = ifc_elem.get_material(element, should_inherit=True) + if not material or not material.is_a("IfcMaterialLayerSetUsage"): + return [] + + usage = material + layers = usage.ForLayerSet.MaterialLayers + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + solid = _get_extrusion_solid(file, element) + if solid is None: + return [] + + depth_m = float(solid.Depth) * unit_scale + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + centroid_x_m = 0.0 + centroid_y_m = 0.0 + x_min_m = x_max_m = 0.0 + y_min_m = y_max_m = 0.0 + if profile_pts_ifc: + xs = [float(p[0]) * unit_scale for p in profile_pts_ifc] + ys = [float(p[1]) * unit_scale for p in profile_pts_ifc] + centroid_x_m = sum(xs) / len(xs) + centroid_y_m = sum(ys) / len(ys) + x_min_m, x_max_m = min(xs), max(xs) + y_min_m, y_max_m = min(ys), max(ys) + + ref_m = float(usage.OffsetFromReferenceLine) * unit_scale + direction_sense = (getattr(usage, "DirectionSense", None) or "POSITIVE") + thickness_axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + sense = 1.0 if direction_sense == "POSITIVE" else -1.0 + + # Collect unique boundary offsets (adjacent layers share a boundary) + boundaries: list[tuple] = [] # (layer_index, layer, face, offset_m) + seen_offsets: set = set() + cumulative_m = ref_m + + for i, layer in enumerate(layers): + thickness_m = float(layer.LayerThickness) * unit_scale + start_m = cumulative_m + end_m = cumulative_m + sense * thickness_m + for face_label, offset_m in (("start", start_m), ("end", end_m)): + key = round(offset_m, 6) + if key not in seen_offsets: + seen_offsets.add(key) + boundaries.append((i, layer, face_label, offset_m)) + cumulative_m = end_m + + def _w(px, py, pz): + return _profile_coords_to_world_m(file, element, solid, px, py, pz, placement_override) + + candidates: list[dict] = [] + for layer_idx, layer, face, offset_m in boundaries: + if thickness_axis == "AXIS3": + # Boundary is a horizontal plane at z = offset_m; span full profile XY extent. + c0 = _w(x_min_m, y_min_m, offset_m) + c1 = _w(x_max_m, y_min_m, offset_m) + c2 = _w(x_max_m, y_max_m, offset_m) + c3 = _w(x_min_m, y_max_m, offset_m) + wp_mid = _w(centroid_x_m, centroid_y_m, offset_m) + elif thickness_axis == "AXIS1": + # Boundary is a plane at profile_x = offset_m; span full Y extent and Z depth. + c0 = _w(offset_m, y_min_m, 0.0) + c1 = _w(offset_m, y_max_m, 0.0) + c2 = _w(offset_m, y_max_m, depth_m) + c3 = _w(offset_m, y_min_m, depth_m) + wp_mid = _w(offset_m, centroid_y_m, depth_m * 0.5) + else: # AXIS2 + # Boundary is a plane at profile_y = offset_m; span full X extent and Z depth. + c0 = _w(x_min_m, offset_m, 0.0) + c1 = _w(x_max_m, offset_m, 0.0) + c2 = _w(x_max_m, offset_m, depth_m) + c3 = _w(x_min_m, offset_m, depth_m) + wp_mid = _w(centroid_x_m, offset_m, depth_m * 0.5) + + if wp_mid is None: + continue + seam_corners = [c for c in (c0, c1, c2, c3) if c is not None] + candidates.append({ + "type": "VERTEX", + "snap_world": wp_mid, + "seam_corners": seam_corners, + "layer": layer, + "layer_index": layer_idx, + "face": face, + "offset_from_ref_m": offset_m, + }) + + return candidates + + +def build_anchor_from_layer_boundary( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + snap: dict, +) -> dict: + """Build a FACE/LAYER_BOUNDARY anchor from a layer snap candidate. + + :param snap: Dict from ``get_layer_snap_candidates`` with keys + ``snap_world``, ``layer``, ``layer_index``, ``face``, ``offset_from_ref_m``. + :return: Anchor dict ready for JSON serialisation into BBIM_Dimension. + """ + world_pos = snap["snap_world"] + layer = snap["layer"] + mat = getattr(layer, "Material", None) + return { + "guid": element.GlobalId, + "type": "FACE", + "addr": { + "method": "LAYER_BOUNDARY", + "layer_id": layer.id(), + "layer_material_id": mat.id() if mat else None, + "layer_material_name": mat.Name if mat else None, + "layer_category": getattr(layer, "Category", None), + "layer_index": snap["layer_index"], + "face": snap["face"], + "offset_from_ref_m": snap["offset_from_ref_m"], + }, + "hint": list(world_pos), + "pt": list(world_pos), + } + + +def get_profile_snap_candidates( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + placement_override: Optional[dict] = None, +) -> list[dict]: + """Return snap candidates derived from an element's IfcExtrudedAreaSolid profile. + + Each candidate dict has: + - ``type``: ``"VERTEX"`` or ``"EDGE"`` + - ``snap_world``: world-space snap position in metres + - ``profile_x_m``, ``profile_y_m``: position in profile-local 2-D space, metres + - ``extrusion_z_m``: distance along ExtrudedDirection, metres + - ``snap``: ``"VERTEX"`` or ``"EDGE"`` (resolution hint) + - For EDGE candidates: ``v0``, ``v1`` world-space endpoints (for GPU indicator) + + Returns ``[]`` when the element has no IfcExtrudedAreaSolid representation. + + Candidates generated: + - Base vertices — each profile vertex at extrusion_z_m = 0 + - Top vertices — each profile vertex at extrusion_z_m = depth_m + - Base horiz edges — adjacent profile vertex pairs at extrusion_z_m = 0 + - Top horiz edges — adjacent profile vertex pairs at extrusion_z_m = depth_m + - Vertical edges — same profile vertex at z=0 and z=depth_m (mid z) + """ + solid = _get_extrusion_solid(file, element) + if solid is None: + return [] + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + if not profile_pts_ifc: + return [] + + depth_m = float(solid.Depth) * unit_scale + n = len(profile_pts_ifc) + pts_m = [(float(p[0]) * unit_scale, float(p[1]) * unit_scale) for p in profile_pts_ifc] + candidates: list[dict] = [] + + def world_pos(pt_idx: int, z_m: float): + return _profile_vert_to_world_m(file, element, solid, pt_idx, z_m, placement_override) + + def midpoint(pa, pb): + return ((pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5, (pa[2] + pb[2]) * 0.5) + + for z_m in (0.0, depth_m): + for i, (px_m, py_m) in enumerate(pts_m): + wp = world_pos(i, z_m) + if wp is None: + continue + candidates.append({ + "type": "VERTEX", "snap_world": wp, + "profile_x_m": px_m, "profile_y_m": py_m, + "extrusion_z_m": z_m, "snap": "VERTEX", + }) + + j = (i + 1) % n + wpb = world_pos(j, z_m) + if wpb is not None: + jpx_m, jpy_m = pts_m[j] + candidates.append({ + "type": "EDGE", + "snap_world": midpoint(wp, wpb), + "v0": wp, "v1": wpb, + "profile_x_m": (px_m + jpx_m) * 0.5, + "profile_y_m": (py_m + jpy_m) * 0.5, + "extrusion_z_m": z_m, "snap": "EDGE", + }) + + for i, (px_m, py_m) in enumerate(pts_m): + wpa = world_pos(i, 0.0) + wpb = world_pos(i, depth_m) + if wpa is not None and wpb is not None: + candidates.append({ + "type": "EDGE", + "snap_world": midpoint(wpa, wpb), + "v0": wpa, "v1": wpb, + "profile_x_m": px_m, "profile_y_m": py_m, + "extrusion_z_m": depth_m * 0.5, "snap": "EDGE", + }) + + return candidates + + +# --------------------------------------------------------------------------- +# Profile anchor resolution +# --------------------------------------------------------------------------- + + +def _get_extrusion_solid(file: ifcopenshell.file, element: ifcopenshell.entity_instance): + """Return the first IfcExtrudedAreaSolid found in the element's representations.""" + if not hasattr(element, "Representation") or not element.Representation: + return None + for rep in element.Representation.Representations: + for item in rep.Items: + solid = _unwrap_to_solid(item) + if solid is not None and solid.is_a("IfcExtrudedAreaSolid"): + return solid + return None + + +def _unwrap_to_solid(item): + """Recursively unwrap IfcMappedItem / IfcBooleanResult to find the underlying solid.""" + if item.is_a("IfcMappedItem"): + items = item.MappingSource.MappedRepresentation.Items + return _unwrap_to_solid(items[0]) if items else None + if item.is_a("IfcBooleanResult"): + return _unwrap_to_solid(item.FirstOperand) + return item + + +def _get_profile_points_ifc(profile) -> list[tuple[float, float]]: + """Return list of (x, y) profile vertices in IFC project units. + + Supports IfcRectangleProfileDef (derives 4 corners) and + IfcArbitraryClosedProfileDef with IfcIndexedPolyCurve or IfcPolyline outer curves. + """ + if profile.is_a("IfcRectangleProfileDef"): + xd = float(profile.XDim) + yd = float(profile.YDim) + hx, hy = xd / 2.0, yd / 2.0 + cx, cy = 0.0, 0.0 + if hasattr(profile, "Position") and profile.Position and profile.Position.Location: + loc = profile.Position.Location.Coordinates + cx, cy = float(loc[0]), float(loc[1]) + return [(cx - hx, cy - hy), (cx + hx, cy - hy), (cx + hx, cy + hy), (cx - hx, cy + hy)] + + if profile.is_a("IfcArbitraryClosedProfileDef"): + outer = profile.OuterCurve + if outer.is_a("IfcIndexedPolyCurve"): + coord_list = outer.Points.CoordList + return [(float(c[0]), float(c[1])) for c in coord_list] + if outer.is_a("IfcPolyline"): + pts = [(float(p.Coordinates[0]), float(p.Coordinates[1])) for p in outer.Points] + if len(pts) > 1 and pts[0] == pts[-1]: + pts = pts[:-1] + return pts + + return [] + + +def _apply_axis2placement3d_m( + file: ifcopenshell.file, + placement, + pt_m: tuple[float, float, float], +) -> tuple[float, float, float]: + """Apply an IfcAxis2Placement3D to a point that is already in metres. + + Location.Coordinates are in IFC project units and are scaled by unit_scale. + Rotation basis vectors (Axis, RefDirection) are dimensionless. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + loc = placement.Location.Coordinates + ox = float(loc[0]) * unit_scale + oy = float(loc[1]) * unit_scale + oz = float(loc[2]) * unit_scale + + if placement.Axis: + zr = placement.Axis.DirectionRatios + zm = math.sqrt(zr[0] ** 2 + zr[1] ** 2 + zr[2] ** 2) + zx, zy, zz = (zr[0] / zm, zr[1] / zm, zr[2] / zm) if zm > 1e-12 else (0.0, 0.0, 1.0) + else: + zx, zy, zz = 0.0, 0.0, 1.0 + + if placement.RefDirection: + xr = placement.RefDirection.DirectionRatios + xm = math.sqrt(xr[0] ** 2 + xr[1] ** 2 + xr[2] ** 2) + xx, xy, xz = (xr[0] / xm, xr[1] / xm, xr[2] / xm) if xm > 1e-12 else (1.0, 0.0, 0.0) + else: + xx, xy, xz = 1.0, 0.0, 0.0 + + yx = zy * xz - zz * xy + yy = zz * xx - zx * xz + yz = zx * xy - zy * xx + + px, py, pz = pt_m + return ( + ox + px * xx + py * yx + pz * zx, + oy + px * xy + py * yy + pz * zy, + oz + px * xz + py * yz + pz * zz, + ) + + +def _profile_vert_to_world_m( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + solid, + pt_idx: int, + extrusion_z_m: float, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Convert a profile vertex index + extrusion distance to world-space metres. + + Coordinate flow (all in metres after unit_scale): + 1. Profile 2D point → scale IFC coords by unit_scale + 2. Add extrusion offset along normalized ExtrudedDirection + 3. Apply solid.Position (IfcAxis2Placement3D) → element-local metres + 4. Apply element ObjectPlacement → world metres + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + if not profile_pts_ifc or pt_idx < 0 or pt_idx >= len(profile_pts_ifc): + return None + + px_m = float(profile_pts_ifc[pt_idx][0]) * unit_scale + py_m = float(profile_pts_ifc[pt_idx][1]) * unit_scale + + dr = solid.ExtrudedDirection.DirectionRatios + mag = math.sqrt(sum(d * d for d in dr)) + if mag < 1e-12: + return None + ex, ey, ez = dr[0] / mag, dr[1] / mag, dr[2] / mag + + pt_solid_m = ( + px_m + ex * float(extrusion_z_m), + py_m + ey * float(extrusion_z_m), + ez * float(extrusion_z_m), + ) + + if solid.Position: + pt_elem_m = _apply_axis2placement3d_m(file, solid.Position, pt_solid_m) + else: + pt_elem_m = pt_solid_m + + return _local_to_world_m(file, element, pt_elem_m, placement_override) + + +def _profile_coords_to_world_m( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + solid, + px_m: float, + py_m: float, + extrusion_z_m: float, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Convert profile-local coordinates (metres) + extrusion distance to world-space metres. + + Identical coordinate flow to ``_profile_vert_to_world_m`` but takes the + profile 2-D position directly instead of a CoordList index. + """ + dr = solid.ExtrudedDirection.DirectionRatios + mag = math.sqrt(sum(d * d for d in dr)) + if mag < 1e-12: + return None + ex, ey, ez = dr[0] / mag, dr[1] / mag, dr[2] / mag + + pt_solid_m = ( + px_m + ex * float(extrusion_z_m), + py_m + ey * float(extrusion_z_m), + ez * float(extrusion_z_m), + ) + + if solid.Position: + pt_elem_m = _apply_axis2placement3d_m(file, solid.Position, pt_solid_m) + else: + pt_elem_m = pt_solid_m + + return _local_to_world_m(file, element, pt_elem_m, placement_override) + + +def _resolve_profile_local_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a PROFILE_LOCAL anchor to world-space metres. + + For VERTEX snap type, re-snaps to the nearest current profile vertex so that + the anchor tracks correctly even when CoordList ordering changes after an + ``update_representation`` call. For EDGE snap type the stored midpoint + coordinates are used directly (the midpoint is already between two vertices + and is unambiguous after reordering). + """ + profile_x_m = addr.get("profile_x_m") + profile_y_m = addr.get("profile_y_m") + extrusion_z_m = addr.get("extrusion_z_m") + snap_type = addr.get("snap", "VERTEX") + + if profile_x_m is None or profile_y_m is None or extrusion_z_m is None: + return None + + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + + if snap_type == "VERTEX": + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + profile_pts = _get_profile_points_ifc(solid.SweptArea) + if profile_pts: + best_px, best_py, best_d2 = profile_x_m, profile_y_m, float("inf") + for pt in profile_pts: + px = float(pt[0]) * unit_scale + py = float(pt[1]) * unit_scale + d2 = (px - profile_x_m) ** 2 + (py - profile_y_m) ** 2 + if d2 < best_d2: + best_d2, best_px, best_py = d2, px, py + profile_x_m, profile_y_m = best_px, best_py + + return _profile_coords_to_world_m( + file, element, solid, profile_x_m, profile_y_m, extrusion_z_m, placement_override + ) + + +def _resolve_profile_vert_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + pt_idx = addr.get("pt_idx") + extrusion_z_m = addr.get("extrusion_z_m") + if pt_idx is None or extrusion_z_m is None: + return None + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + return _profile_vert_to_world_m(file, element, solid, pt_idx, extrusion_z_m, placement_override) + + +def _resolve_profile_edge_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a PROFILE_EDGE anchor to the world-space midpoint of the stored edge.""" + pt_idx_a = addr.get("pt_idx_a") + extrusion_z_m_a = addr.get("extrusion_z_m_a") + pt_idx_b = addr.get("pt_idx_b") + extrusion_z_m_b = addr.get("extrusion_z_m_b") + if any(v is None for v in (pt_idx_a, extrusion_z_m_a, pt_idx_b, extrusion_z_m_b)): + return None + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + pa = _profile_vert_to_world_m(file, element, solid, pt_idx_a, extrusion_z_m_a, placement_override) + pb = _profile_vert_to_world_m(file, element, solid, pt_idx_b, extrusion_z_m_b, placement_override) + if pa is None or pb is None: + return None + return ((pa[0] + pb[0]) * 0.5, (pa[1] + pb[1]) * 0.5, (pa[2] + pb[2]) * 0.5) + + +# --------------------------------------------------------------------------- +# Layer boundary anchor resolution +# --------------------------------------------------------------------------- + + +def _get_material_layer_usage(element: ifcopenshell.entity_instance): + """Return the IfcMaterialLayerSetUsage for an element, or None.""" + import ifcopenshell.util.element as ifc_elem + mat = ifc_elem.get_material(element, should_inherit=True) + return mat if (mat and mat.is_a("IfcMaterialLayerSetUsage")) else None + + +def _resolve_layer_boundary_anchor( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + addr: dict, + placement_override: Optional[dict] = None, +) -> Optional[tuple[float, float, float]]: + """Resolve a LAYER_BOUNDARY anchor to world-space metres via a 6-tier fallback stack. + + Resolution tiers (first match wins): + 1. layer_id → IfcMaterialLayer STEP id (most stable) + 2. layer_material_id → IfcMaterial STEP id + 3. layer_material_name → IfcMaterial.Name + 4. layer_category → IfcMaterialLayer.Category (IFC4) + 5. layer_index → 0-based position in layer set + 6. Geometric fallback → caller uses stored ``pt`` + """ + usage = _get_material_layer_usage(element) + if usage is None: + return None + + layers = list(usage.ForLayerSet.MaterialLayers) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + + # --- Identify target layer via tier stack --- + target_idx: Optional[int] = None + + layer_id = addr.get("layer_id") + if layer_id is not None and target_idx is None: + for i, layer in enumerate(layers): + if layer.id() == layer_id: + target_idx = i + break + + if target_idx is None: + layer_material_id = addr.get("layer_material_id") + if layer_material_id is not None: + for i, layer in enumerate(layers): + mat = getattr(layer, "Material", None) + if mat and mat.id() == layer_material_id: + target_idx = i + break + + if target_idx is None: + layer_material_name = addr.get("layer_material_name") + if layer_material_name: + for i, layer in enumerate(layers): + mat = getattr(layer, "Material", None) + if mat and mat.Name == layer_material_name: + target_idx = i + break + + if target_idx is None: + layer_category = addr.get("layer_category") + if layer_category: + for i, layer in enumerate(layers): + if getattr(layer, "Category", None) == layer_category: + target_idx = i + break + + if target_idx is None: + stored_idx = addr.get("layer_index") + if stored_idx is not None and 0 <= stored_idx < len(layers): + target_idx = stored_idx + + if target_idx is None: + return None # tier 6: caller will use stored pt + + # --- Compute boundary offset along thickness axis --- + ref_m = float(usage.OffsetFromReferenceLine) * unit_scale + direction_sense = (getattr(usage, "DirectionSense", None) or "POSITIVE") + thickness_axis = (getattr(usage, "LayerSetDirection", None) or "AXIS2") + sense = 1.0 if direction_sense == "POSITIVE" else -1.0 + + cumulative_m = ref_m + target_offset_m: Optional[float] = None + for i, layer in enumerate(layers): + thickness_m = float(layer.LayerThickness) * unit_scale + if i == target_idx: + face = addr.get("face", "start") + target_offset_m = cumulative_m if face == "start" else cumulative_m + sense * thickness_m + break + cumulative_m += sense * thickness_m + + if target_offset_m is None: + return None + + # --- Convert to world position at profile centroid, mid-extrusion --- + solid = _get_extrusion_solid(file, element) + if solid is None: + return None + + depth_m = float(solid.Depth) * unit_scale + profile_pts_ifc = _get_profile_points_ifc(solid.SweptArea) + centroid_x_m = 0.0 + centroid_y_m = 0.0 + if profile_pts_ifc: + xs = [float(p[0]) * unit_scale for p in profile_pts_ifc] + ys = [float(p[1]) * unit_scale for p in profile_pts_ifc] + centroid_x_m = sum(xs) / len(xs) + centroid_y_m = sum(ys) / len(ys) + + if thickness_axis == "AXIS3": + return _profile_coords_to_world_m( + file, element, solid, centroid_x_m, centroid_y_m, target_offset_m, placement_override + ) + elif thickness_axis == "AXIS1": + return _profile_coords_to_world_m( + file, element, solid, target_offset_m, centroid_y_m, depth_m * 0.5, placement_override + ) + else: # AXIS2 + return _profile_coords_to_world_m( + file, element, solid, centroid_x_m, target_offset_m, depth_m * 0.5, placement_override + ) + + +# --------------------------------------------------------------------------- +# Mesh extraction helpers +# --------------------------------------------------------------------------- + + +def _get_shape(file, element, settings, shape_cache): + if shape_cache is None: + shape_cache = {} + elem_id = element.id() + if elem_id in shape_cache: + return shape_cache[elem_id] + + if settings is None: + settings = ifcopenshell.geom.settings() + # Do NOT set USE_WORLD_COORDS — tessellate in local (element-origin) space. + # The geom kernel caches by representation ID; with USE_WORLD_COORDS=True, + # moving an element would return stale world-space coords from the cache. + # We apply the current placement manually via placement_override. + settings.set("APPLY_DEFAULT_MATERIALS", False) + + try: + shape = ifcopenshell.geom.create_shape(settings, element) + except Exception: + shape = None + + shape_cache[elem_id] = shape + return shape + + +def _local_to_world_m( + file: ifcopenshell.file, + element: ifcopenshell.entity_instance, + local_pt_m: tuple, + placement_override: Optional[dict] = None, +) -> tuple[float, float, float]: + """Convert a local-space point (metres, from create_shape without USE_WORLD_COORDS) + to a world-space point in metres. + + When *placement_override* contains the element's STEP id, that 4Ɨ4 matrix + (row-major, already in metres — typically ``np.array(obj.matrix_world)``) is + used instead of reading ``element.ObjectPlacement`` from the IFC file. This + ensures that elements moved in the Blender viewport but not yet explicitly + synced to IFC (via "Edit Object Placement") are handled correctly. + + Without an override, falls back to ``get_local_placement`` which reads the IFC + placement and scales IFC-unit translation to metres via ``unit_scale``. + """ + x, y, z = float(local_pt_m[0]), float(local_pt_m[1]), float(local_pt_m[2]) + if placement_override is not None and element.id() in placement_override: + m = placement_override[element.id()] # 4Ɨ4, metres, row-major + return ( + float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3]), + float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3]), + float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3]), + ) + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + return ( + float(m[0][0] * x + m[0][1] * y + m[0][2] * z + m[0][3] * unit_scale), + float(m[1][0] * x + m[1][1] * y + m[1][2] * z + m[1][3] * unit_scale), + float(m[2][0] * x + m[2][1] * y + m[2][2] * z + m[2][3] * unit_scale), + ) + + +def _rotate_local_to_world( + element: ifcopenshell.entity_instance, + local_vec: tuple, + placement_override: Optional[dict] = None, +) -> tuple[float, float, float]: + """Rotate a direction vector from local to world space (no translation).""" + x, y, z = float(local_vec[0]), float(local_vec[1]), float(local_vec[2]) + if placement_override is not None and element.id() in placement_override: + m = placement_override[element.id()] + return ( + float(m[0][0] * x + m[0][1] * y + m[0][2] * z), + float(m[1][0] * x + m[1][1] * y + m[1][2] * z), + float(m[2][0] * x + m[2][1] * y + m[2][2] * z), + ) + m = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + return ( + float(m[0][0] * x + m[0][1] * y + m[0][2] * z), + float(m[1][0] * x + m[1][1] * y + m[1][2] * z), + float(m[2][0] * x + m[2][1] * y + m[2][2] * z), + ) + + +def _extract_mesh(shape) -> tuple[list[tuple], list[tuple]]: + """Return (verts, tris) from a tessellated shape.""" + vf = shape.geometry.verts + ff = shape.geometry.faces + verts = [(vf[i * 3], vf[i * 3 + 1], vf[i * 3 + 2]) for i in range(len(vf) // 3)] + tris = [(ff[i * 3], ff[i * 3 + 1], ff[i * 3 + 2]) for i in range(len(ff) // 3)] + return verts, tris + + +# --------------------------------------------------------------------------- +# Coplanar face grouping +# --------------------------------------------------------------------------- + +_NORMAL_THRESHOLD = 0.005 # max angle deviation between coplanar normals (~0.3°) +_PLANE_THRESHOLD = 1e-4 # max distance from origin along normal (metres — matches geom output) + + +def _tri_normal(v0, v1, v2) -> tuple[float, float, float]: + ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2] + bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2] + nx = ay * bz - az * by + ny = az * bx - ax * bz + nz = ax * by - ay * bx + mag = math.sqrt(nx * nx + ny * ny + nz * nz) + if mag < 1e-12: + return (0.0, 0.0, 0.0) + return (nx / mag, ny / mag, nz / mag) + + +def _dot(a, b) -> float: + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + + +def _group_coplanar_tris(verts: list, tris: list) -> list[list[int]]: + """Group triangle indices whose faces are coplanar (same normal + plane).""" + n_tris = len(tris) + normals: list[tuple] = [] + plane_d: list[float] = [] + + for a, b, c in tris: + n = _tri_normal(verts[a], verts[b], verts[c]) + normals.append(n) + # plane distance: n Ā· centroid + cx = (verts[a][0] + verts[b][0] + verts[c][0]) / 3 + cy = (verts[a][1] + verts[b][1] + verts[c][1]) / 3 + cz = (verts[a][2] + verts[b][2] + verts[c][2]) / 3 + plane_d.append(n[0] * cx + n[1] * cy + n[2] * cz) + + assigned = [False] * n_tris + groups: list[list[int]] = [] + + for i in range(n_tris): + if assigned[i]: + continue + group = [i] + assigned[i] = True + ni, di = normals[i], plane_d[i] + if ni == (0.0, 0.0, 0.0): + groups.append(group) + continue + for j in range(i + 1, n_tris): + if assigned[j]: + continue + nj, dj = normals[j], plane_d[j] + if nj == (0.0, 0.0, 0.0): + continue + dot_val = _dot(ni, nj) # signed — opposite normals (dotā‰ˆ-1) must NOT merge + if dot_val > 1.0 - _NORMAL_THRESHOLD and abs(di - dj) < _PLANE_THRESHOLD: + group.append(j) + assigned[j] = True + groups.append(group) + + return groups + + +def _tri_area(v0, v1, v2) -> float: + ax, ay, az = v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2] + bx, by, bz = v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2] + cx = ay * bz - az * by + cy = az * bx - ax * bz + cz = ax * by - ay * bx + return 0.5 * math.sqrt(cx * cx + cy * cy + cz * cz) + + +def _face_group_props(group: list[int], verts: list, tris: list) -> dict: + """Compute normal, total area, and area-weighted centroid for a face group.""" + total_area = 0.0 + wx = wy = wz = 0.0 + nx = ny = nz = 0.0 + + for idx in group: + a, b, c = tris[idx] + va, vb, vc = verts[a], verts[b], verts[c] + area = _tri_area(va, vb, vc) + total_area += area + cx = (va[0] + vb[0] + vc[0]) / 3 + cy = (va[1] + vb[1] + vc[1]) / 3 + cz = (va[2] + vb[2] + vc[2]) / 3 + wx += cx * area + wy += cy * area + wz += cz * area + n = _tri_normal(va, vb, vc) + nx += n[0] * area + ny += n[1] * area + nz += n[2] * area + + if total_area < 1e-12: + return {"normal": (0.0, 0.0, 1.0), "area": 0.0, "centroid": (wx, wy, wz)} + + centroid = (wx / total_area, wy / total_area, wz / total_area) + + mag = math.sqrt(nx * nx + ny * ny + nz * nz) + if mag > 1e-12: + normal: tuple[float, ...] = (nx / mag, ny / mag, nz / mag) + else: + normal = (0.0, 0.0, 1.0) + + return {"normal": normal, "area": total_area, "centroid": centroid} + + +# --------------------------------------------------------------------------- +# Face group matching +# --------------------------------------------------------------------------- + +_NORMAL_MATCH_THRESHOLD = 0.02 # max dot-product deviation for normal match +_CENTROID_MAX_DIST = 10.0 # max IFC-unit distance for centroid proximity + + +def _dist(a, b) -> float: + return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) + + +def _best_group( + group_props: list[dict], + hit_normal: tuple, + hit_location: tuple, +) -> Optional[tuple[int, dict]]: + """Return (index, props) for the best face group matching a ray-cast hit.""" + best_score = -1.0 + best = None + + for i, props in enumerate(group_props): + dot_val = _dot(props["normal"], hit_normal) + if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD: + continue + dist = _dist(props["centroid"], hit_location) + score = dot_val - dist / max(_CENTROID_MAX_DIST, 0.001) * 0.2 + if score > best_score: + best_score = score + best = (i, props) + + return best + + +def _find_by_local_normal( + local_group_props: list[dict], + world_group_props: list[dict], + fp_normal_local: list, + hint: Optional[list], +) -> Optional[tuple[float, float, float]]: + """Return the world-space centroid of the face group whose element-local normal + best matches *fp_normal_local*. Matching in local space is rotation-invariant — + moving or rotating the element does not change local normals, so the anchor + correctly tracks the same face through placement changes and profile edits.""" + best_score = -1.0 + best_centroid = None + + for i, lp in enumerate(local_group_props): + dot_val = _dot(lp["normal"], fp_normal_local) + if dot_val < 1.0 - _NORMAL_MATCH_THRESHOLD: + continue + score = dot_val + if hint: + hint_dist = _dist(world_group_props[i]["centroid"], hint) + score -= hint_dist / max(_CENTROID_MAX_DIST, 0.001) * 0.1 + if score > best_score: + best_score = score + best_centroid = world_group_props[i]["centroid"] + + return best_centroid + + +# --------------------------------------------------------------------------- +# Misc helpers +# --------------------------------------------------------------------------- + + +def _pt_or_none(pt) -> Optional[tuple[float, float, float]]: + if pt: + return (float(pt[0]), float(pt[1]), float(pt[2])) + return None diff --git a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py index ff847b39eb..100e886214 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py +++ b/src/ifcopenshell-python/ifcopenshell/api/feature/remove_feature.py @@ -22,11 +22,13 @@ import ifcopenshell.util.element def remove_feature(file: ifcopenshell.file, feature: ifcopenshell.entity_instance) -> None: - """Remove a feature + """Permanently delete a feature element and its void or projection relationship. - Fillings are retained as orphans. Featured elements remain. Features - cannot exist by themselves, so not only is the relationship removed, the - feature is also removed. + The feature entity (e.g. IfcOpeningElement) is removed from the model + along with its IfcRelVoidsElement or IfcRelProjectsElement relationship. + The host element (wall, slab, etc.) is unaffected. Any fillings (windows, + doors) that occupied the opening become orphaned and must be separately + deleted via root.remove_product. :param feature: The IfcFeatureElement to remove. diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py index 44728c2902..d845f4dc83 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/__init__.py @@ -25,7 +25,10 @@ geometry extrusions). from .. import wrap_usecases from .add_axis_representation import add_axis_representation +from .add_topology_representation import add_topology_representation from .add_boolean import add_boolean +from .clip_solid import clip_solid +from .clip_solid_bounded import clip_solid_bounded from .add_door_representation import add_door_representation from .add_footprint_representation import add_footprint_representation from .add_mesh_representation import add_mesh_representation @@ -50,6 +53,7 @@ from .disconnect_element import disconnect_element from .disconnect_path import disconnect_path from .edit_object_placement import edit_object_placement from .map_representation import map_representation +from .copy_representation import copy_representation from .regenerate_wall_representation import regenerate_wall_representation from .remove_boolean import remove_boolean from .remove_representation import remove_representation @@ -60,7 +64,11 @@ wrap_usecases(__path__, __name__) __all__ = [ "add_axis_representation", + "add_topology_representation", "add_boolean", + "clip_solid", + "clip_solid_bounded", + "copy_representation", "add_door_representation", "add_footprint_representation", "add_mesh_representation", diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py index 910df42d8c..80ebf5bb89 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_boolean.py @@ -31,17 +31,6 @@ def add_boolean( ) -> list[ifcopenshell.entity_instance]: """Adds a boolean operation to two or more representation items - If an IfcBooleanOperand is part of the top level items in an - IfcShapeRepresentation, it will be removed from that level whilst being - added to the IfcBooleanResult. This is because it is generally intuitive - that an item is either participating in a boolean operation, or being an - item in its own right, but not both. - - However, if an IfcBooleanOperand is part of another boolean operation - already, it will not be removed from the existing operation. A new - operation will be created, and therefore it will participate in two - operations. - This function protects against recursive booleans. After a boolean operation is made, since the items of @@ -101,9 +90,6 @@ def add_boolean( booleans = [] for second_item in second_items: - for inverse in file.get_inverse(second_item): - if inverse.is_a("IfcShapeRepresentation"): - inverse.Items = list(set(inverse.Items) - {second_item}) if first.is_a("IfcTesselatedFaceSet"): first.Closed = True # For now, trust the user to do the right thing. if second_item.is_a("IfcTesselatedFaceSet"): diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py index 7cb9368f68..e756cb07cf 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_representation.py @@ -20,11 +20,11 @@ from __future__ import annotations import math from typing import TYPE_CHECKING, Any, Literal, Optional, Union -import bmesh # pyright: ignore[reportMissingImports] -import bpy # pyright: ignore[reportMissingImports] +import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] +import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import numpy as np import numpy.typing as npt -from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] +from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.shape_builder import ifcopenshell.util.unit diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py new file mode 100644 index 0000000000..e01284fd4f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_topology_representation.py @@ -0,0 +1,97 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +from typing import Optional + +import ifcopenshell + +_ITEM_TYPE_TO_REP_TYPE = { + "IfcVertex": "Vertex", + "IfcVertexPoint": "Vertex", + "IfcEdge": "Edge", + "IfcOrientedEdge": "Edge", + "IfcEdgeCurve": "Edge", + "IfcEdgeLoop": "Edge", + "IfcPath": "Edge", + "IfcFace": "Face", + "IfcFaceSurface": "Face", + "IfcAdvancedFace": "Face", + "IfcClosedShell": "Face", + "IfcOpenShell": "Face", + "IfcConnectedFaceSet": "Face", +} + + +def add_topology_representation( + file: ifcopenshell.file, + context: ifcopenshell.entity_instance, + item: ifcopenshell.entity_instance, + representation_identifier: Optional[str] = None, + representation_type: Optional[str] = None, +) -> ifcopenshell.entity_instance: + """Adds an IfcTopologyRepresentation for a structural element + + Structural analysis elements (IfcStructuralSurfaceMember, + IfcStructuralCurveMember) use topology representations rather than solid + geometry. This is analogous to :func:`add_axis_representation` and + :func:`add_profile_representation` but produces an + IfcTopologyRepresentation instead of an IfcShapeRepresentation. + + The representation type ("Face", "Edge", "Vertex") is inferred from the + item's IFC class if not provided explicitly. + + :param context: The IfcGeometricRepresentationContext for the + representation, typically a Reference context. + :param item: The IfcTopologicalRepresentationItem (e.g. IfcFaceSurface, + IfcEdge) to include in the representation. + :param representation_identifier: The RepresentationIdentifier string. + Defaults to the context's ContextIdentifier. + :param representation_type: The RepresentationType string ("Face", + "Edge", "Vertex"). Inferred from item class if not given. + :return: The newly created IfcTopologyRepresentation entity. + + Example: + + .. code:: python + + context = ifcopenshell.util.representation.get_context( + model, "Model", "Reference", "GRAPH_VIEW") + face = model.createIfcFaceSurface(bounds, surface, True) + rep = ifcopenshell.api.geometry.add_topology_representation( + model, context=context, item=face) + ifcopenshell.api.geometry.assign_representation( + model, product=member, representation=rep) + """ + if representation_identifier is None: + representation_identifier = context.ContextIdentifier + + if representation_type is None: + for ifc_class, rep_type in _ITEM_TYPE_TO_REP_TYPE.items(): + if item.is_a(ifc_class): + representation_type = rep_type + break + else: + representation_type = "Undefined" + + return file.createIfcTopologyRepresentation( + context, + representation_identifier, + representation_type, + [item], + ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py index ff62bae474..c542471896 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_wall_representation.py @@ -47,7 +47,9 @@ def add_wall_representation( :param thickness: The thickness of the wall in meters. :param x_angle: The slope angle along the wall's X-axis, in radians. :param clippings: List of clipping definitions. Clippings can be `Clipping` objects - or dictionaries of arguments for `Clipping.parse`. + or dictionaries of arguments for `Clipping.parse`. Each clipping has a + ``normal`` that points toward the removed material (the discarded side), + not toward the kept material; see :func:`clip_solid` for details. :param booleans: List of any existing IfcBooleanResults. :return: IfcShapeRepresentation. """ diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py new file mode 100644 index 0000000000..a83fd4385f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid.py @@ -0,0 +1,86 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from __future__ import annotations + +import json +from typing import Optional, Sequence + +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.unit +from ifcopenshell.util.data import Clipping + + +def clip_solid( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], + element: Optional[ifcopenshell.entity_instance] = None, +) -> ifcopenshell.entity_instance: + """Clip a solid with a half-space plane, returning an IfcBooleanClippingResult. + + Convenience wrapper around :class:`ifcopenshell.util.data.Clipping` for + use with any solid. This is the same convention used by the ``clippings`` + parameter of :func:`add_wall_representation`. + + .. warning:: + + The ``normal`` points toward the **removed** material (the discarded + side), not toward the kept material. For a slope clip the normal + points upward into the removed wedge above the slope line. For a + side mitre the normal points outward away from the wall body. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example — trim an extruded solid to a lean-to slope (removed material is + above the slope):: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid", model, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], # points UP toward removed material + ) + + :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``, + or ``IfcBooleanClippingResult``). + :param location: A point on the clipping plane in the representation's + local coordinate system. + :param normal: Plane normal pointing toward the material to be removed + (see warning above). + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + clipping = Clipping(location=tuple(location), normal=tuple(normal)) + result = clipping.apply(file, item, unit_scale) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py new file mode 100644 index 0000000000..ac2e39c741 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/clip_solid_bounded.py @@ -0,0 +1,116 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from __future__ import annotations + +import json +from typing import Optional, Sequence + +import numpy as np + +import ifcopenshell.api.pset +import ifcopenshell.util.element +import ifcopenshell.util.unit +from ifcopenshell.util.shape_builder import ShapeBuilder + + +def clip_solid_bounded( + file: ifcopenshell.file, + item: ifcopenshell.entity_instance, + location: Sequence[float], + normal: Sequence[float], + boundary_points: Sequence[Sequence[float]], + boundary_position: Sequence[float] = (0.0, 0.0, 0.0), + element: Optional[ifcopenshell.entity_instance] = None, +) -> ifcopenshell.entity_instance: + """Clip a solid with a polygonally bounded half-space, returning an IfcBooleanClippingResult. + + Like :func:`clip_solid`, but the boolean subtraction is restricted to the + region enclosed by ``boundary_points`` rather than extending across the + entire half-space. The clipping plane is still infinite, but material is + only removed within the extruded footprint of the polygon. + + The ``normal`` convention is the same as :func:`clip_solid`: it points + toward the **removed** material. + + After clipping, set the parent ``IfcShapeRepresentation`` + ``RepresentationType`` to ``"Clipping"``. + + Example:: + + bcr = ifcopenshell.api.run( + "geometry.clip_solid_bounded", model, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + + :param item: The solid to clip (``IfcSweptAreaSolid``, ``IfcSweptDiskSolid``, + or ``IfcBooleanClippingResult``). + :param location: A point on the clipping plane in the representation's + local coordinate system. + :param normal: Plane normal pointing toward the material to be removed. + :param boundary_points: 2D ``[x, y]`` points defining the closed polygonal + boundary in the coordinate system of ``boundary_position``. The polygon + is automatically closed — do not repeat the first point. + :param boundary_position: 3D origin of the boundary coordinate system + (axes default to the global X/Y/Z directions). Defaults to the origin. + :param element: If provided, the resulting ``IfcBooleanClippingResult`` is + registered in the element's ``BBIM_Boolean`` property set so that + :func:`regenerate_wall_representation` preserves it during regeneration. + :return: The resulting ``IfcBooleanClippingResult``. + """ + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(file) + builder = ShapeBuilder(file) + + normal_arr = np.array(normal) + if np.allclose(normal_arr, [0.0, 0.0, 1.0], atol=1e-2) or np.allclose(normal_arr, [0.0, 0.0, -1.0], atol=1e-2): + arbitrary_vector = np.array([0.0, 1.0, 0.0]) + else: + arbitrary_vector = np.array([0.0, 0.0, 1.0]) + x_axis = np.cross(normal_arr, arbitrary_vector) + x_axis /= np.linalg.norm(x_axis) + + scaled_location = [i / unit_scale for i in location] + plane_placement = builder.create_axis2_placement_3d(scaled_location, normal, x_axis) + plane = file.create_entity("IfcPlane", plane_placement) + + scaled_boundary_position = [i / unit_scale for i in boundary_position] + boundary_pos_entity = file.create_entity( + "IfcAxis2Placement3D", + file.create_entity("IfcCartesianPoint", scaled_boundary_position), + ) + + scaled_pts = [[p[0] / unit_scale, p[1] / unit_scale] for p in boundary_points] + scaled_pts.append(scaled_pts[0]) # close the polygon + ifc_pts = [file.create_entity("IfcCartesianPoint", p) for p in scaled_pts] + boundary = file.createIfcPolyline(ifc_pts) + + half_space = file.create_entity("IfcPolygonalBoundedHalfSpace", plane, False, boundary_pos_entity, boundary) + result = file.create_entity("IfcBooleanClippingResult", "DIFFERENCE", item, half_space) + if element is not None: + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Boolean") + if pset_data: + pset = file.by_id(pset_data["id"]) + data = list(set(json.loads(pset_data["Data"]) + [result.id()])) + else: + pset = ifcopenshell.api.pset.add_pset(file, product=element, name="BBIM_Boolean") + data = [result.id()] + ifcopenshell.api.pset.edit_pset(file, pset=pset, properties={"Data": json.dumps(data)}) + return result diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py index 3135bef116..64c9fddb62 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/connect_path.py @@ -31,6 +31,7 @@ def connect_path( relating_connection: str = "NOTDEFINED", related_connection: str = "NOTDEFINED", description: Optional[str] = None, + connection_geometry: Optional[ifcopenshell.entity_instance] = None, ) -> ifcopenshell.entity_instance: incompatible_connections: list[ifcopenshell.entity_instance] = [] for rel in relating_element.ConnectedTo: @@ -73,6 +74,7 @@ def connect_path( ifcopenshell.guid.new(), OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), Description=description, + ConnectionGeometry=connection_geometry, RelatingElement=relating_element, RelatedElement=related_element, RelatingConnectionType=relating_connection, diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py new file mode 100644 index 0000000000..c2b4c485a9 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/copy_representation.py @@ -0,0 +1,76 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +from __future__ import annotations + +from typing import Optional + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.representation + + +def copy_representation( + file: ifcopenshell.file, + source: ifcopenshell.entity_instance, + target: ifcopenshell.entity_instance, + context_identifier: str = "Body", +) -> Optional[ifcopenshell.entity_instance]: + """Copy a geometric representation from one element to another. + + Finds the named representation on ``source``, deep-copies its entity + graph (geometry items, profiles, placements, etc.), and assigns the copy + to ``target``. Representation contexts are shared rather than copied. + If ``target`` already has a matching representation it is removed and + replaced. + + If no matching representation is found on ``source``, returns ``None`` + and leaves ``target`` unchanged. + + :param source: The element to copy the representation from. + :param target: The element to assign the copied representation to. + :param context_identifier: The RepresentationIdentifier to look up on + ``source`` (e.g. ``"Body"``, ``"Axis"``, ``"Box"``). + Defaults to ``"Body"``. + :return: The newly created IfcShapeRepresentation, or None if no + matching representation was found on ``source``. + + Example: + + .. code:: python + + wall_a = model.by_id(1) + wall_b = model.by_id(2) + + # Give wall_b the same body geometry as wall_a. + ifcopenshell.api.geometry.copy_representation(model, + source=wall_a, target=wall_b) + """ + source_rep = ifcopenshell.util.representation.get_representation(source, "Model", context_identifier) + if source_rep is None: + return None + + new_rep = ifcopenshell.util.element.copy_deep(file, source_rep, exclude=["IfcGeometricRepresentationContext"]) + + existing_rep = ifcopenshell.util.representation.get_representation(target, "Model", context_identifier) + if existing_rep: + ifcopenshell.api.geometry.unassign_representation(file, product=target, representation=existing_rep) + ifcopenshell.api.geometry.remove_representation(file, representation=existing_rep) + + ifcopenshell.api.geometry.assign_representation(file, product=target, representation=new_rep) + return new_rep diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py index 7fbe44ce12..0d86b997f5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py @@ -52,10 +52,11 @@ def edit_object_placement( :param is_si: If True, the matrix is given in SI units. If false, in project units. :param should_transform_children: A child element is a nested element, - opening, filling, etc. If true, child elements will move along with the - parent. If false, child elements will stay where they are. Because most - placements in IFC are relative, this means that if a child moves, we - actually don't change their placement. + opening, filling, etc. If True, child elements move along with the + parent; pass True when moving an assembly (roof, furniture group, etc.) + and you want all children to follow. If False (default), child elements + keep their current world positions; their local placements are rewritten + to compensate for the parent move. :return: The new or updated IfcLocalPlacement entity """ usecase = Usecase() diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py index 9143577438..e59c6e1efa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/regenerate_wall_representation.py @@ -69,6 +69,12 @@ def regenerate_wall_representation( additional extrusions are generated for each connection that boolean difference the base extrusion. + Clippings applied via :func:`geometry.clip_solid` or + :func:`geometry.clip_solid_bounded` are preserved only if the ``element`` + parameter was passed when creating them, which registers the result in the + ``BBIM_Boolean`` property set. Clippings created without that parameter + are silently discarded during regeneration. + This will also update the axis line representation (e.g. trim the axis line to any connections). diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py index 46bec8757b..3731b2fffc 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/validate_type.py @@ -83,6 +83,7 @@ def validate_type( if remaining_items: ifcopenshell.api.geometry.add_boolean(file, preferred_item, remaining_items, "UNION") + representation.Items = [i for i in representation.Items if i not in remaining_items] representation.RepresentationType = ifcopenshell.util.representation.guess_type(representation.Items) if representation.RepresentationType == "CSG": diff --git a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py index 43ccaea2bc..c7e6dfba80 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py +++ b/src/ifcopenshell-python/ifcopenshell/api/georeference/add_georeferencing.py @@ -17,6 +17,7 @@ # along with IfcOpenShell. If not, see . import ifcopenshell +import ifcopenshell.api.georeference import ifcopenshell.api.pset import ifcopenshell.util.element @@ -63,8 +64,13 @@ def add_georeferencing(file: ifcopenshell.file, ifc_class: str = "IfcMapConversi }, ) return - if file.by_type("IfcProjectedCRS"): + has_crs = bool(file.by_type("IfcProjectedCRS")) + has_conversion = bool(file.by_type("IfcCoordinateOperation")) + if has_crs and has_conversion: return + if has_crs or has_conversion: + # This is technically invalid, but we shall forgive the industry here if they are wrong ... + ifcopenshell.api.georeference.remove_georeferencing(file) source_crs = None for context in file.by_type("IfcGeometricRepresentationContext", include_subtypes=False): if context.ContextType == "Model": diff --git a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py index 652fc89f56..f9bcacba8d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py +++ b/src/ifcopenshell-python/ifcopenshell/api/grid/remove_grid_axis.py @@ -42,7 +42,5 @@ def remove_grid_axis(file: ifcopenshell.file, axis: ifcopenshell.entity_instance ifcopenshell.api.grid.remove_grid_axis(model, axis=axis_2) """ axis_curve = axis.AxisCurve - if file.get_total_inverses(axis_curve) == 1: - ifcopenshell.util.element.remove_deep(file, axis_curve) - file.remove(axis_curve) file.remove(axis) + ifcopenshell.util.element.remove_deep2(file, axis_curve) diff --git a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py index d3432b617f..579d0e7a11 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py +++ b/src/ifcopenshell-python/ifcopenshell/api/nest/assign_object.py @@ -19,7 +19,9 @@ from typing import Union import ifcopenshell +import ifcopenshell.api.aggregate import ifcopenshell.api.owner +import ifcopenshell.api.spatial import ifcopenshell.guid import ifcopenshell.util.element @@ -137,7 +139,10 @@ def assign_object( if not objects_to_change: return is_nested_by - # NOTE: An object can both be nested and assigned to a container or an aggregate. + # Can be either only nested, aggregated, or contained at the same time. + possibly_contained = [o for o in objects_without_nests if hasattr(o, "ContainedInStructure")] + ifcopenshell.api.spatial.unassign_container(file, products=possibly_contained) + ifcopenshell.api.aggregate.unassign_object(file, products=objects_without_nests) # unassign elements from previous nests for nests in previous_nests_rels: diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 0f09bd4991..8a8f2307a4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -431,7 +431,7 @@ class Usecase: ) ifcopenshell.api.type.assign_type( self.file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] related_objects=[element], relating_type=new_type, should_map_representations=False, diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py index e122ba766b..a28f6dfa46 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/create_file.py @@ -53,9 +53,7 @@ def create_file(version: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4") -> ifcope """ file = ifcopenshell.file(schema=version) file.header.file_name.name = "/dev/null" # Hehehe - file.header.file_name.time_stamp = ( - datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc).astimezone().replace(microsecond=0).isoformat() - ) + file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version) file.header.file_name.originating_system = "IfcOpenShell {}".format(ifcopenshell.version) file.header.file_name.authorization = "Nobody" diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py index ca8d5ba55e..2f81494bc7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_prop_template.py @@ -22,9 +22,9 @@ import ifcopenshell.util.element def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.entity_instance) -> None: """Removes a property template - Note that a property set template should always have at least one - property template to be valid, so take care when removing property - templates. + Note that a property set template should always have at least one property + template to be valid. So a property set template will not be removed if it + is the only template ina a property ste template. :param prop_template: The IfcSimplePropertyTemplate to remove. :return: None @@ -43,10 +43,8 @@ def remove_prop_template(file: ifcopenshell.file, prop_template: ifcopenshell.en ifcopenshell.api.pset_template.remove_prop_template(model, prop_template=prop2) """ for inverse in file.get_inverse(prop_template): - if len(inverse.HasPropertyTemplates) == 1: - inverse.HasPropertyTemplates = [] - else: + if len(inverse.HasPropertyTemplates) > 1: has_property_templates = list(inverse.HasPropertyTemplates) has_property_templates.remove(prop_template) inverse.HasPropertyTemplates = has_property_templates - ifcopenshell.util.element.remove_deep(file, prop_template) + ifcopenshell.util.element.remove_deep2(file, prop_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py index 07d5cf6daf..5ff3cf6c38 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/remove_pset_template.py @@ -38,4 +38,4 @@ def remove_pset_template(file: ifcopenshell.file, pset_template: ifcopenshell.en # Let's remove the template. ifcopenshell.api.pset_template.remove_pset_template(model, pset_template=template) """ - ifcopenshell.util.element.remove_deep(file, pset_template) + ifcopenshell.util.element.remove_deep2(file, pset_template) diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py index fc67a8d3c7..ceb49681b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/add_resource_quantity.py @@ -79,5 +79,5 @@ def add_resource_quantity( old_quantity = resource.BaseQuantity resource.BaseQuantity = quantity if old_quantity: - ifcopenshell.util.element.remove_deep(file, old_quantity) + ifcopenshell.util.element.remove_deep2(file, old_quantity) return quantity diff --git a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py index a6b014e1db..afb470565d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py +++ b/src/ifcopenshell-python/ifcopenshell/api/resource/remove_resource_quantity.py @@ -47,4 +47,4 @@ def remove_resource_quantity(file: ifcopenshell.file, resource: ifcopenshell.ent old_quantity = resource.BaseQuantity resource.BaseQuantity = None if old_quantity: - ifcopenshell.util.element.remove_deep(file, old_quantity) + ifcopenshell.util.element.remove_deep2(file, old_quantity) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py index 302445483b..e7cca1dafa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_process.py @@ -26,7 +26,7 @@ def assign_process( relating_process: ifcopenshell.entity_instance, related_object: ifcopenshell.entity_instance, ) -> ifcopenshell.entity_instance: - """Assigns an object to be related to a process, typically a construction task + """Assigns an object as an input, control, or resource of a process Processes work using the ICOM (Input, Controls, Outputs, Mechanisms) paradigm in IFC. This process model is commonly used in modeling @@ -63,6 +63,17 @@ def assign_process( For resources, any construction resource may be assigned to a task. + .. warning:: + + This function creates an **Input** relationship + (``IfcRelAssignsToProcess``), meaning the product is *consumed* or + *operated on* by the task — the typical case is demolition or + maintenance. + + If the task *constructs or installs* a product (e.g. erecting a wall + or fitting a window), use :func:`assign_product` instead, which + creates an **Output** relationship (``IfcRelAssignsToProduct``). + :param relating_process: The IfcProcess (typically IfcTask) that the input, control, or resource is related to. :param related_object: The IfcProduct (for input), IfcCostItem (for @@ -77,7 +88,7 @@ def assign_process( # need to be part of a work schedule. schedule = ifcopenshell.api.sequence.add_work_schedule(model, name="Construction Schedule A") - # Let's create a construction task. Note that the predefined type is + # Let's create a demolition task. Note that the predefined type is # important to distinguish types of tasks. task = ifcopenshell.api.sequence.add_task(model, work_schedule=schedule, name="Demolish existing", identification="A", predefined_type="DEMOLITION") @@ -85,8 +96,12 @@ def assign_process( # Let's say we have a wall somewhere. wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") - # Let's demolish that wall! + # The wall is an INPUT to the demolition task (it will be consumed). ifcopenshell.api.sequence.assign_process(model, relating_process=task, related_object=wall) + + # For a construction task that BUILDS a wall, use assign_product instead: + # build_task = ifcopenshell.api.sequence.add_task(model, ..., predefined_type="CONSTRUCTION") + # ifcopenshell.api.sequence.assign_product(model, relating_product=wall, related_object=build_task) """ if related_object.HasAssignments: for assignment in related_object.HasAssignments: diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py index 2baa262432..8a21e73602 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/__init__.py @@ -30,7 +30,9 @@ from .add_structural_load import add_structural_load from .add_structural_load_case import add_structural_load_case from .add_structural_load_group import add_structural_load_group from .add_structural_member_connection import add_structural_member_connection +from .assign_product import assign_product from .assign_structural_analysis_model import assign_structural_analysis_model +from .assign_to_building import assign_to_building from .edit_structural_analysis_model import edit_structural_analysis_model from .edit_structural_boundary_condition import edit_structural_boundary_condition from .edit_structural_connection_cs import edit_structural_connection_cs @@ -57,7 +59,9 @@ __all__ = [ "add_structural_load_case", "add_structural_load_group", "add_structural_member_connection", + "assign_product", "assign_structural_analysis_model", + "assign_to_building", "edit_structural_analysis_model", "edit_structural_boundary_condition", "edit_structural_connection_cs", diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py new file mode 100644 index 0000000000..a30672facd --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_product.py @@ -0,0 +1,64 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.root + + +def assign_product( + file: ifcopenshell.file, + relating_product: ifcopenshell.entity_instance, + related_object: ifcopenshell.entity_instance, +) -> ifcopenshell.entity_instance: + """Links an object to a product via IfcRelAssignsToProduct + + Typically used to associate a physical building element with a structural + analysis member (IfcStructuralSurfaceMember, IfcStructuralCurveMember) so + that analysis results can be traced back to the physical model. + + :param relating_product: The IfcProduct that the object is assigned to, + typically an IfcStructuralMember. + :param related_object: The IfcObjectDefinition being assigned, typically + a physical building element such as an IfcWall or IfcSlab. + :return: The IfcRelAssignsToProduct relationship. + + Example: + + .. code:: python + + wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") + member = ifcopenshell.api.root.create_entity( + model, ifc_class="IfcStructuralSurfaceMember") + ifcopenshell.api.structural.assign_product(model, + relating_product=member, related_object=wall) + """ + for rel in relating_product.ReferencedBy or []: + if not rel.is_a("IfcRelAssignsToProduct"): + continue + if related_object in rel.RelatedObjects: + return rel + related_objects = list(rel.RelatedObjects) + related_objects.append(related_object) + rel.RelatedObjects = related_objects + return rel + + rel = ifcopenshell.api.root.create_entity(file, ifc_class="IfcRelAssignsToProduct") + rel.RelatingProduct = relating_product + rel.RelatedObjects = [related_object] + return rel diff --git a/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py new file mode 100644 index 0000000000..7e70f20272 --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/structural/assign_to_building.py @@ -0,0 +1,64 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell +import ifcopenshell.api.owner +import ifcopenshell.guid + + +def assign_to_building( + file: ifcopenshell.file, + structural_analysis_model: ifcopenshell.entity_instance, + building: ifcopenshell.entity_instance, +) -> ifcopenshell.entity_instance: + """Associates a structural analysis model with a building via IfcRelServicesBuildings + + The existing :func:`assign_structural_analysis_model` handles + IfcRelAssignsToGroup (linking structural members to the analysis model). + This function handles the separate model-to-building relationship, which + records which building the structural analysis model serves. + + :param structural_analysis_model: The IfcStructuralAnalysisModel to + associate with the building. + :param building: The IfcBuilding (or other IfcSpatialStructureElement) + that the structural analysis model serves. + :return: The IfcRelServicesBuildings relationship. + + Example: + + .. code:: python + + building = ifcopenshell.util.selector.filter_elements(model, "IfcBuilding")[0] + model_ = ifcopenshell.api.structural.add_structural_analysis_model(model) + ifcopenshell.api.structural.assign_to_building(model, + structural_analysis_model=model_, building=building) + """ + for rel in structural_analysis_model.ServicesBuildings or []: + if building in rel.RelatedBuildings: + return rel + rel.RelatedBuildings = list(rel.RelatedBuildings) + [building] + return rel + + return file.create_entity( + "IfcRelServicesBuildings", + ifcopenshell.guid.new(), + OwnerHistory=ifcopenshell.api.owner.create_owner_history(file), + RelatingSystem=structural_analysis_model, + RelatedBuildings=[building], + ) diff --git a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py index 3db088f83e..7ef4858041 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py +++ b/src/ifcopenshell-python/ifcopenshell/api/style/add_surface_textures.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING, Any, Optional import ifcopenshell if TYPE_CHECKING: - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] def add_surface_textures( diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index b6b72a192b..7ae790e753 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -65,6 +65,8 @@ def disconnect_port(file: ifcopenshell.file, port: ifcopenshell.entity_instance) rels += port.ConnectedFrom or () for rel in rels: + rel.RelatingPort.FlowDirection = None + rel.RelatedPort.FlowDirection = None history = rel.OwnerHistory file.remove(rel) if history: diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py index 36ab2f73a2..2611df0b4e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/remove_unit.py @@ -47,4 +47,5 @@ def remove_unit(file: ifcopenshell.file, unit: ifcopenshell.entity_instance) -> unit_assignment.Units = units else: file.remove(unit_assignment) - ifcopenshell.util.element.remove_deep(file, unit) + # TODO handle other possible unit inverses + ifcopenshell.util.element.remove_deep2(file, unit) diff --git a/src/ifcopenshell-python/ifcopenshell/draw.py b/src/ifcopenshell-python/ifcopenshell/draw.py index 5f6d761ceb..ba78f0d48d 100644 --- a/src/ifcopenshell-python/ifcopenshell/draw.py +++ b/src/ifcopenshell-python/ifcopenshell/draw.py @@ -42,6 +42,8 @@ WHITE = numpy.array((1.0, 1.0, 1.0)) DO_NOTHING = lambda *args: None +ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, "arrange_polygon_settings") else None + @dataclass class draw_settings: @@ -536,7 +538,7 @@ def main( *(tup for i, tup in enumerate(zip(path_objects, section_polies, polies)) if has_relevant_zone(i)) ) - arranged = W.arrange_polygons(polies) + arranged = W.arrange_polygons(*filter(None, (ARRANGE_POLYGON_SETTINGS,)), polies) svg_data_3 = W.polygons_to_svg(arranged, False) dom3 = parseString(svg_data_3) svg3 = dom3.childNodes[0] diff --git a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py index d7595ad6cb..3981dbc421 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/schema_class.py +++ b/src/ifcopenshell-python/ifcopenshell/express/schema_class.py @@ -420,7 +420,15 @@ class SchemaClass(codegen.Base): if isinstance(type, nodes.AggregationType): aggr_type = type.aggregate_type - make_bound = lambda b: -1 if b == "?" else int(b) + + def make_bound(b): + # `?` and non-literal bounds (attribute references, arithmetic expressions) collapse to -1. + # + try: + return int(b) + except (TypeError, ValueError): + return -1 + bound1, bound2 = map(make_bound, (type.bounds.lower, type.bounds.upper)) decl_type = get_declared_type(type.type, emitted_names) return x.aggregation_type(aggr_type, bound1, bound2, decl_type) @@ -547,7 +555,16 @@ class SchemaClass(codegen.Base): inv_attrs = [] for attr in type.inverse: if attr.bounds: - make_bound = lambda b: -1 if b == "?" else int(b) + + def make_bound(b): + # `?` and non-literal bounds (attribute references, arithmetic + # expressions) collapse to -1 (unbounded) — the C++ runtime has + # no third state for "dynamic cardinality". + try: + return int(b) + except (TypeError, ValueError): + return -1 + bound1, bound2 = map(make_bound, (attr.bounds.lower, attr.bounds.upper)) else: bound1, bound2 = -1, -1 diff --git a/src/ifcopenshell-python/ifcopenshell/geom/__init__.py b/src/ifcopenshell-python/ifcopenshell/geom/__init__.py index 606004864c..2b01d63925 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/__init__.py @@ -33,14 +33,14 @@ def _has_occ(): # Previous versions (pythonocc<=0.17.3) are using just OCC. try: - import OCC.Core.BRepTools # pyright: ignore[reportMissingImports] + import OCC.Core.BRepTools # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] return True except ImportError: pass try: - import OCC.BRepTools # noqa: F401 # pyright: ignore[reportMissingImports] + import OCC.BRepTools # noqa: F401 # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] return True except ImportError: diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 299f09281a..9c36e4b933 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -28,7 +28,7 @@ from ..file import file from . import has_occ if TYPE_CHECKING: - from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] + from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] IteratorOutput = Union["ShapeElementType", "utils.shape_tuple"] @@ -47,9 +47,9 @@ if has_occ: from . import occ_utils as utils try: - from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] + from OCC.Core import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] except ImportError: - from OCC import TopoDS # pyright: ignore[reportMissingImports] + from OCC import TopoDS # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] def wrap_shape_creation(settings: settings, shape: ifcopenshell_wrapper.Element): if getattr(settings, "use_python_opencascade", False): @@ -89,6 +89,7 @@ SETTING = Literal[ "keep-bounding-boxes", "layerset-first", "length-unit", + "make-volume", "max-offset-deviation", "max-offset", "mesher-angular-deflection", @@ -124,6 +125,7 @@ SERIALIZER_SETTING = Literal[ "ecef", "digits", "wkt-use-section", + "separate-z-up-node", ] # NOTE: hybrid-cgal-simple-opencascade is added just as an example diff --git a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py index 9a4a44c18f..15a4dfc838 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/occ_utils.py @@ -26,17 +26,33 @@ import warnings from collections.abc import Iterable from typing import NamedTuple, Union -import OCC # pyright: ignore[reportMissingImports] +import OCC # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] from typing_extensions import assert_never import ifcopenshell.ifcopenshell_wrapper as ifcopenshell_wrapper try: - from OCC.Core import AIS, BRepTools, Graphic3d, Quantity, TopoDS, V3d, gp # pyright: ignore[reportMissingImports] + from OCC.Core import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] + AIS, + BRepTools, + Graphic3d, + Quantity, + TopoDS, + V3d, + gp, + ) USE_OCCT_HANDLE = False except ImportError: - from OCC import AIS, BRepTools, Graphic3d, Quantity, TopoDS, V3d, gp # pyright: ignore[reportMissingImports] + from OCC import ( # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] + AIS, + BRepTools, + Graphic3d, + Quantity, + TopoDS, + V3d, + gp, + ) USE_OCCT_HANDLE = True @@ -68,7 +84,7 @@ DEFAULT_STYLES = { def initialize_display(): - import OCC.Display.SimpleGui # pyright: ignore[reportMissingImports] + import OCC.Display.SimpleGui # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] global handle, main_loop, add_menu, add_function_to_menu handle, main_loop, add_menu, add_function_to_menu = OCC.Display.SimpleGui.init_display() diff --git a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi index c906f57055..28caafc262 100644 --- a/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi +++ b/src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.pyi @@ -104,6 +104,7 @@ class IfcSpfHeader: https://standards.buildingsmart.org/documents/Implementation/ImplementationGuide_IFCHeaderData_Version_1.0.2.pdf """ + def __init__(self, *args): ... def file(self, *args): ... def file_description_py(self): ... def file_name_py(self): ... @@ -113,7 +114,8 @@ class IfcSpfHeader: def write(self, out): ... class BRep(Representation): - def as_compound(self, force_meters): ... + def __init__(self, settings, entity, id, shapes): ... + def as_compound(self, force_meters=False): ... def begin(self): ... def calculate_projected_surface_area(self, ax, along_x, along_y, along_z): ... def calculate_surface_area(self, arg2): ... @@ -125,6 +127,7 @@ class BRep(Representation): def size(self): ... class BRepElement(Element): + def __init__(self, id, parent_id, name, type, guid, context, trsf, geometry, product): ... def calculate_projected_surface_area(self, along_x, along_y, along_z): ... @property def geometry(self) -> BRep: ... @@ -135,6 +138,7 @@ class BRepElement(Element): def volume(self): ... class ColladaSerializer(WriteOnlyGeometrySerializer): + def __init__(self, dae_filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def object_id(self, o): ... @@ -150,8 +154,9 @@ class ConversionResult: def Shape(self): ... def Style(self): ... def StylePtr(self): ... + def __init__(self, *args): ... def append(self, trsf): ... - def apply_transform(self, unit_scale): ... + def apply_transform(self, unit_scale=1.0): ... def hasStyle(self): ... def prepend(self, trsf): ... def setStyle(self, newStyle): ... @@ -159,6 +164,7 @@ class ConversionResult: class ConversionResultShape: def Serialize(self, place, arg3): ... def Triangulate(self, *args): ... + def __init__(self, *args, **kwargs): ... def add(self, arg2): ... def area(self): ... def axis(self): ... @@ -193,6 +199,7 @@ class ConversionResultShape: def wrap_in_compound(self): ... class DoubleArray3: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -206,6 +213,7 @@ class DoubleArray3: def swap(self, v): ... class Element: + def __init__(self, settings, id, parent_id, name, type, guid, context, trsf, product): ... # TODO: Remove from the wrapper? def SetParents(self, newparents): ... # TODO: could it be None? @@ -266,6 +274,7 @@ class Element: class GeometrySerializer: READ_BREP: Any READ_TRIANGULATION: Any + def __init__(self, *args, **kwargs): ... def geometry_settings(self, *args): ... def isTesselated(self): ... def object_id(self, o): ... @@ -275,6 +284,7 @@ class GeometrySerializer: def write(self, *args): ... class GltfSerializer(WriteOnlyGeometrySerializer): + def __init__(self, filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -284,6 +294,7 @@ class GltfSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class HdfSerializer(GeometrySerializer): + def __init__(self, hdf_filename, geometry_settings, settings, read_only=False): ... def finalize(self): ... def isTesselated(self): ... def read(self, *args): ... @@ -295,6 +306,7 @@ class HdfSerializer(GeometrySerializer): def writeHeader(self): ... class IfcBaseEntity(entity_instance): + def __init__(self, *args, **kwargs): ... def declaration(self): ... def get(self, name): ... def get_inverse(self, name): ... @@ -302,26 +314,31 @@ class IfcBaseEntity(entity_instance): def set_id(self, i): ... class IfcBaseType(entity_instance): + def __init__(self, *args, **kwargs): ... def declaration(self): ... -class IfcEntityInstanceData: ... +class IfcEntityInstanceData: + def __init__(self, *args, **kwargs): ... class IfcLateBoundEntity(IfcBaseEntity): + def __init__(self, decl, data): ... def declaration(self): ... class InstanceStreamer: + def __init__(self, *args): ... def bypassTypes(self, type_names): ... def bypassed_instances(self): ... coerce_attribute_count: bool def hasSemicolon(self): ... def inverses(self, *args): ... def pushPage(self, page): ... - def readInstancePy(self, type_as_declaration_instance): ... + def readInstancePy(self, type_as_declaration_instance=False): ... def references(self, *args): ... def semicolonCount(self): ... def status(self): ... class Iterator: + def __init__(self, *args): ... initialization_outcome_: Any processed_: Any def bounds_max(self): ... @@ -367,25 +384,36 @@ class Iterator: class JsonSerializer: JSON_DIALECT_CREOOX: Any + def __init__(self, *args): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... def writeHeader(self): ... +# TODO: MakeVolume is ignored in SWIG, remove from stub once build is bumped. +class MakeVolume: + defaultvalue: Any + description: Any + name: Any + class OpaqueCoordinate_3: + def __init__(self, *args): ... def get(self, i): ... def set(self, i, n): ... class OpaqueCoordinate_4: + def __init__(self, *args): ... def get(self, i): ... def set(self, i, n): ... class OpaqueNumber: + def __init__(self, *args, **kwargs): ... def clone(self): ... def to_double(self): ... def to_string(self): ... class Representation: + def __init__(self, settings, entity, id): ... def entity(self): ... @property def id(self) -> str: @@ -398,18 +426,21 @@ class Representation: def settings(self): ... class RocksDBPrefixIterator: + def __init__(self, storage, prefix): ... def key(self): ... def next(self): ... def valid(self): ... def value(self): ... class RocksDbSerializer: - def finalize(self): ... - def ready(self): ... - def setFile(self, arg2): ... - def writeHeader(self): ... + def __init__(self, *args): ... + def finalize(self) -> None: ... + def ready(self) -> bool: ... + def setFile(self, arg2) -> None: ... + def writeHeader(self) -> None: ... class Serialization(Representation): + def __init__(self, brep): ... @property def brep_data(self): ... @property @@ -418,6 +449,7 @@ class Serialization(Representation): def surface_styles(self): ... class SerializedElement(Element): + def __init__(self, shape_model): ... @property def geometry(self) -> Serialization: ... @@ -434,6 +466,7 @@ class Settings: def setting_names(self): ... class SvgSerializer(WriteOnlyGeometrySerializer): + def __init__(self, out_filename, geometry_settings, settings): ... SH_NONE: Any SH_FULL: Any SH_LEFT: Any @@ -479,8 +512,8 @@ class SvgSerializer(WriteOnlyGeometrySerializer): def setPrintSpaceNames(self, b): ... def setProfileThreshold(self, i): ... def setScale(self, s): ... - def setSectionHeight(self, h, storey): ... - def setSectionHeightsFromStoreys(self, offset): ... + def setSectionHeight(self, h, storey=None): ... + def setSectionHeightsFromStoreys(self, offset=1.2): ... def setSectionRef(self, s): ... def setSegmentProjection(self, b): ... def setSpaceNameTransform(self, v): ... @@ -497,22 +530,25 @@ class SvgSerializer(WriteOnlyGeometrySerializer): def writeHeader(self): ... class SwigPyIterator: + def __init__(self, *args, **kwargs): ... def advance(self, n): ... def copy(self): ... - def decr(self, n): ... + def decr(self, n=1): ... def distance(self, x): ... def equal(self, x): ... - def incr(self, n): ... + def incr(self, n=1): ... def next(self): ... def previous(self): ... def value(self): ... class Transformation: + def __init__(self, settings, matrix): ... def data(self): ... @property def matrix(self): ... class Triangulation(Representation): + def __init__(self, *args): ... def addEdge(self, item_id, style, i0, i1): ... def addFace(self, *args): ... def addNormal(self, X, Y, Z): ... @@ -567,21 +603,24 @@ class Triangulation(Representation): def verts_buffer(self) -> bytes: ... class TriangulationElement(Element): + def __init__(self, *args): ... @property def geometry(self) -> Triangulation: ... def geometry_pointer(self): ... class TtlWktSerializer(WriteOnlyGeometrySerializer): + def __init__(self, filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... def setFile(self, arg2): ... def setUnitNameAndMagnitude(self, arg2, arg3): ... - def ttl_object_id(self, o, postfix): ... + def ttl_object_id(self, o, postfix=None): ... def write(self, *args): ... def writeHeader(self): ... class WaveFrontOBJSerializer(WriteOnlyGeometrySerializer): + def __init__(self, obj_filename, mtl_filename, geometry_settings, settings): ... def finalize(self): ... def isTesselated(self): ... def ready(self): ... @@ -592,9 +631,11 @@ class WaveFrontOBJSerializer(WriteOnlyGeometrySerializer): def writeMaterial(self, style): ... class WriteOnlyGeometrySerializer(GeometrySerializer): + def __init__(self, *args, **kwargs): ... def read(self, *args): ... class XmlSerializer: + def __init__(self, file, xml_filename): ... def finalize(self): ... def ready(self): ... def setFile(self, arg2): ... @@ -603,6 +644,7 @@ class XmlSerializer: class _SwigNonDynamicMeta(type): ... class abstract_arrangement: + def __init__(self, *args, **kwargs): ... def get_face_pairs(self): ... def merge(self, edge_indices): ... def num_edges(self): ... @@ -610,6 +652,7 @@ class abstract_arrangement: def write(self, polygons, progress): ... class aggregation_type(parameter_type): + def __init__(self, type_of_aggregation, bound1, bound2, type_of_element): ... array_type: Any bag_type: Any list_type: Any @@ -622,6 +665,7 @@ class aggregation_type(parameter_type): def type_of_element(self) -> parameter_type: ... class attribute: + def __init__(self, name, type_of_attribute, optional): ... def name(self) -> str: ... def optional(self) -> bool: ... def type_of_attribute(self) -> parameter_type: ... @@ -662,11 +706,13 @@ class bspline_surface(surface): def kind(self): ... class buffer: + def __init__(self, *args): ... def filename(self): ... def get_value(self): ... def is_ready(self): ... class cant_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -694,6 +740,7 @@ class clash: p2: Any class clashes: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -727,6 +774,7 @@ class collection: def matrix(self): ... class colour(item): + def __init__(self, *args): ... def r(self) -> float: ... def g(self) -> float: ... def b(self) -> float: ... @@ -740,6 +788,7 @@ class colour(item): def kind(self) -> int: ... class context: + def __init__(self, *args): ... def add(self, segments): ... def build(self): ... def get_face_pairs(self): ... @@ -749,7 +798,8 @@ class context: def write(self, arg2): ... class curve(geom_item): - def print_impl(self, o, classname, indent): ... + def __init__(self, *args, **kwargs): ... + def print_impl(self, o, classname, indent=0): ... class cylinder(surface): radius: Any @@ -760,6 +810,7 @@ class cylinder(surface): def matrix(self): ... class declaration: + def __init__(self, name, index_in_schema): ... def _is(self, *args: Union[str, declaration]) -> bool: ... def as_entity(self) -> Union[entity, None]: ... def as_enumeration_type(self) -> Union[enumeration_type, None]: ... @@ -775,6 +826,7 @@ class declaration: def type(self): ... class direction3: + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... @property @@ -786,6 +838,7 @@ class drawing_meta: pln_3d: Any class edge(trimmed_curve): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def kind(self): ... @@ -800,6 +853,7 @@ class ellipse(curve): def matrix(self): ... class entity(declaration): + def __init__(self, name, is_abstract, index_in_schema, supertype): ... def all_attributes(self) -> tuple[attribute, ...]: ... def all_inverse_attributes(self) -> tuple[inverse_attribute, ...]: ... def argument_types(self) -> tuple[str, ...]: @@ -828,6 +882,7 @@ class entity(declaration): def supertype(self) -> Union[entity, None]: ... class entity_instance: + def __init__(self, *args, **kwargs): ... file: ifcopenshell.file """Reference to IFC file to prevent it's garbage collection, if entity is still used.""" @@ -881,11 +936,12 @@ class entity_instance: def setArgumentAsNull(self, i): ... def setArgumentAsString(self, i, a): ... def set_attribute_value(self, *args): ... - def toString(self, arg2, upper): ... - def to_string(self, valid_spf): ... + def toString(self, arg2, upper=False): ... + def to_string(self, valid_spf=True): ... def unset_attribute_value(self, i): ... class enumeration_type(declaration): + def __init__(self, name, index_in_schema, enumeration_items): ... def argument_types(self) -> tuple[str, ...]: ... def as_enumeration_type(self) -> enumeration_type: ... def enumeration_items(self) -> tuple[str, ...]: ... @@ -898,6 +954,7 @@ class enumeration_type(declaration): ... class extrusion(sweep): + def __init__(self, m, basis, dir, d): ... depth: Any direction: Any def calc_hash(self): ... @@ -919,7 +976,8 @@ class face: class file: def FreshId(self): ... - def add(self, entity: entity_instance, id: int) -> entity_instance: ... + def __init__(self, *args): ... + def add(self, entity: entity_instance, id: int = -1) -> entity_instance: ... def addEntities(self, entities): ... def add_type_ref(self, new_entity): ... def batch(self) -> None: @@ -950,7 +1008,7 @@ class file: def by_id(self, id: int) -> entity_instance: ... def by_type(self, *args): ... def by_type_excl_subtypes(self, *args): ... - def bypass_type(self, type_name): ... + def bypass_type(self, type_name: str) -> None: ... calculate_unit_factors: bool check_existance_before_adding: bool def create(self, decl): ... @@ -1010,9 +1068,9 @@ class file: def storage_mode(self): ... def to_string(self): ... @staticmethod - def traverse(instance: entity_instance, max_level: int) -> tuple[entity_instance, ...]: ... + def traverse(instance: entity_instance, max_level: int = -1) -> tuple[entity_instance, ...]: ... @staticmethod - def traverse_breadth_first(instance: entity_instance, max_level: int) -> tuple[entity_instance, ...]: ... + def traverse_breadth_first(instance: entity_instance, max_level: int = -1) -> tuple[entity_instance, ...]: ... def types(self) -> tuple[str, ...]: """Return a tuple of classes present in the file. @@ -1029,9 +1087,11 @@ class file_open_status: UNSUPPORTED_SCHEMA: int INVALID_SYNTAX: int UNKNOWN: int + def __init__(self, *args): ... def value(self): ... class fn_evaluator: + def __init__(self, *args, **kwargs): ... settings_: Any def clone(self): ... def end(self): ... @@ -1040,6 +1100,7 @@ class fn_evaluator: def start(self): ... class function_item(implicit_item): + def __init__(self, *args, **kwargs): ... def calc_hash(self): ... def end(self): ... def kind(self): ... @@ -1047,10 +1108,12 @@ class function_item(implicit_item): def start(self): ... class function_item_evaluator: + def __init__(self, *args): ... def evaluate(self, *args): ... def evaluation_points(self, *args): ... class functor_item(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1058,6 +1121,7 @@ class functor_item(function_item): def start(self): ... class geom_item(item): + def __init__(self, *args, **kwargs): ... matrix: Any surface_style: Any @@ -1071,6 +1135,7 @@ class geometry_conversion_result: representation: Any class gradient_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1082,9 +1147,12 @@ class gradient_function(function_item): class equal_functor: ... class hash_functor: ... class horizontal_plan_at_element: ... -class implicit_item(geom_item): ... + +class implicit_item(geom_item): + def __init__(self, *args, **kwargs): ... class inverse_attribute: + def __init__(self, name, type_of_aggregation, bound1, bound2, entity_reference, attribute_reference): ... bag_type: Any set_type: Any unspecified_type: Any @@ -1097,6 +1165,7 @@ class inverse_attribute: def type_of_aggregation_string(self): ... class item: + def __init__(self, *args, **kwargs): ... instance: Any orientation: Any def calc_hash(self): ... @@ -1119,6 +1188,7 @@ class line(curve): def matrix(self): ... class line_segment: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -1143,7 +1213,8 @@ class loft: class loop: closed: Any external: Any - fi: Any + function_item: Any + tags: Any def calc_hash(self): ... def calculate_linear_edge_curves(self): ... def centroid(self): ... @@ -1160,6 +1231,7 @@ class matrix4(item): AFFINE_W_UNIFORM_SCALE: Any AFFINE_W_NONUNIFORM_SCALE: Any OTHER: Any + def __init__(self, *args): ... tag: Any def calc_hash(self): ... def clone_(self): ... @@ -1172,6 +1244,7 @@ class matrix4(item): def pre_multiply_scale(self, s): ... class named_type(parameter_type): + def __init__(self, declared_type): ... def _is(self, *args): ... def as_named_type(self) -> named_type: ... def declared_type(self) -> declaration: ... @@ -1190,6 +1263,7 @@ class offset_curve(curve): def kind(self): ... class offset_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1211,6 +1285,7 @@ class parameter_type: def as_simple_type(self) -> Union[simple_type, None]: ... class piecewise_function(function_item): + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... def end(self): ... @@ -1230,6 +1305,7 @@ class plane(surface): def matrix(self): ... class point3: + def __init__(self, *args): ... def calc_hash(self): ... def clone_(self): ... @property @@ -1251,6 +1327,7 @@ class ray_intersection_result: style_index: Any class ray_intersection_results: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1275,6 +1352,7 @@ class ray_intersection_results: def swap(self, v): ... class revolve(sweep): + def __init__(self, m, basis, pnt, dir, a): ... angle: Any axis_origin: Any direction: Any @@ -1285,6 +1363,7 @@ class revolve(sweep): def matrix(self): ... class schema_definition: + def __init__(self, name, declarations, factory): ... def declaration_by_name(self, *args: str) -> declaration: """ :return: ``declaration`` but upcasted to the most advanced available type @@ -1306,6 +1385,7 @@ class schema_definition: def name(self) -> str: ... class select_type(declaration): + def __init__(self, name, index_in_schema, select_list): ... def as_select_type(self) -> select_type: ... def select_list(self) -> tuple[declaration, ...]: ... @@ -1320,6 +1400,7 @@ class shell: def print_impl(self, o, indent): ... class simple_type(parameter_type): + def __init__(self, declared_type): ... binary_type: Any boolean_type: Any integer_type: Any @@ -1349,6 +1430,7 @@ class sphere(surface): def matrix(self): ... class style(item): + def __init__(self, *args): ... diffuse: colour name: str """E.g. 'IfcSurfaceStyleShading-218', where 218 is style's STEP id.""" @@ -1383,9 +1465,11 @@ class style(item): def kind(self) -> int: ... -class surface(geom_item): ... +class surface(geom_item): + def __init__(self, *args, **kwargs): ... class svg_groups_of_line_segments: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1410,6 +1494,7 @@ class svg_groups_of_line_segments: def swap(self, v): ... class svg_groups_of_polygons: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1434,6 +1519,7 @@ class svg_groups_of_polygons: def swap(self, v): ... class svg_line_segments: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1458,6 +1544,7 @@ class svg_line_segments: def swap(self, v): ... class svg_loop: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1482,6 +1569,7 @@ class svg_loop: def swap(self, v): ... class svg_loops: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1506,6 +1594,7 @@ class svg_loops: def swap(self, v): ... class svg_point: + def __init__(self, *args): ... def back(self): ... def begin(self): ... def empty(self): ... @@ -1519,6 +1608,7 @@ class svg_point: def swap(self, v): ... class svg_polygons: + def __init__(self, *args): ... def append(self, x): ... def assign(self, n, x): ... def back(self): ... @@ -1543,9 +1633,11 @@ class svg_polygons: def swap(self, v): ... class sweep(geom_item): + def __init__(self, *args, **kwargs): ... basis: Any class sweep_along_curve(sweep): + def __init__(self, *args): ... curve: Any surface: Any direction: Any @@ -1563,6 +1655,7 @@ class torus(surface): def matrix(self): ... class tree: + def __init__(self, *args): ... def add_element(self, *args): ... def add_file(self, *args): ... def clash_clearance_many(self, set_a, set_b, clearance, check_all): ... @@ -1575,7 +1668,7 @@ class tree: def protrusion_distances(self): ... def select(self, *args): ... def select_box(self, *args): ... - def select_ray(self, p0, d, length): ... + def select_ray(self, p0, d, length=1000.0): ... def styles(self): ... def uint8_to_b64(self, uuids_array): ... @staticmethod @@ -1583,6 +1676,7 @@ class tree: def write_h5(self): ... class trimmed_curve(geom_item): + def __init__(self, *args, **kwargs): ... basis: Any curve_sense: Any start: Any @@ -1594,6 +1688,7 @@ class type_by_kind: max: Any class type_declaration(declaration): + def __init__(self, name, index_in_schema, declared_type): ... def argument_types(self): ... def as_type_declaration(self) -> type_declaration: ... def declared_type(self): ... @@ -1612,7 +1707,7 @@ def create_epeck(*args): ... def create_shape(*args): ... def flatten(deep): ... def get_feature(x): ... -def get_info_cpp(v, include_identifier): ... +def get_info_cpp(v, include_identifier=True): ... def get_log(): ... def guess_file_type(fn): ... def helmert_curve_point(A0, A1, A2, s): ... @@ -1622,14 +1717,14 @@ def line_segments_to_polygons(s, eps, segments): ... def map_shape(settings, instance): ... def nary_union(sequence): ... def new_IfcBaseClass(schema_identifier: str, name: str) -> entity_instance: ... -def open(fn, readonly): ... +def open(fn: str, readonly: bool = False) -> file: ... def parse_ifcxml(filename): ... def polygons_to_svg(*args): ... def read(data): ... def register_schema(arg1): ... def schema_by_name(arg1: str) -> schema_definition: ... def schema_names() -> tuple[str, ...]: ... -def serialise(schema_name, shape_str, advanced): ... +def serialise(schema_name, shape_str, advanced=True): ... def set_feature(x, v): ... def set_log_format_json(): ... def set_log_format_text(): ... diff --git a/src/ifcopenshell-python/ifcopenshell/simple_spf b/src/ifcopenshell-python/ifcopenshell/simple_spf index ed50b756c4..9400d243d8 160000 --- a/src/ifcopenshell-python/ifcopenshell/simple_spf +++ b/src/ifcopenshell-python/ifcopenshell/simple_spf @@ -1 +1 @@ -Subproject commit ed50b756c4035290d50eb2d928f89423f3aa0947 +Subproject commit 9400d243d880dace57490949d74ab1932ce99a09 diff --git a/src/ifcopenshell-python/ifcopenshell/util/cost.py b/src/ifcopenshell-python/ifcopenshell/util/cost.py index 875594f1a5..4354e49e90 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/cost.py +++ b/src/ifcopenshell-python/ifcopenshell/util/cost.py @@ -196,9 +196,12 @@ def get_cost_items_for_product(product: ifcopenshell.entity_instance) -> list[if :return: A list of IfcCostItem objects representing the cost items related to the product. """ cost_items = [] - for assignment in product.HasAssignments: - if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl.is_a("IfcCostItem"): - cost_items.append(assignment.RelatingControl) + for assignment in product.HasAssignments or []: + if assignment.is_a("IfcRelAssignsToControl"): + control = assignment.RelatingControl + if control and control.is_a("IfcCostItem"): + cost_items.append(control) + return cost_items diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 149606cb14..8a8a817336 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -469,7 +469,7 @@ def get_properties( del data["HasProperties"] results[prop_name] = data if verbose: - results[prop_name] = {"id": data["id"], "class": data["class"], "value": results[prop_name]} + results[prop_name] = {"id": data["id"], "class": data["type"], "value": results[prop_name]} return results @@ -1234,7 +1234,9 @@ def get_controls(element: ifcopenshell.entity_instance) -> Generator[ifcopenshel yield rel.RelatingControl -def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: +def get_parent( + element: ifcopenshell.entity_instance, ifc_class: Optional[str] = None +) -> Union[ifcopenshell.entity_instance, None]: """Get the parent in the spatial heirarchy IFC features a spatial hierarchy tree of all objects. Each spatial element @@ -1251,6 +1253,8 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti - Voiding: the opening voids another physical element, such as a hole in a wall :param element: Any physical or spatial element in the tree + :param ifc_class: Optionally filter the type of parent you're after. For + example, you may be after the storey, not a space. :return: Its parent. This must exist for any valid file, or None if we've reached the IfcProject. Example: @@ -1260,7 +1264,7 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti element = file.by_type("IfcWall")[0] parent = ifcopenshell.util.element.get_parent(element) """ - return ( + parent = ( get_container(element, should_get_direct=True) or get_aggregate(element) or get_nest(element) @@ -1268,6 +1272,16 @@ def get_parent(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.enti or get_voided_element(element) ) + if not ifc_class: + return parent + + while parent: + if parent.is_a(ifc_class): + return parent + parent = get_parent(parent) + + return None + def get_filled_void(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """If the element is filling a void, get the void diff --git a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py index 5ba2466f29..1c3b6cb001 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py +++ b/src/ifcopenshell-python/ifcopenshell/util/scripts/validate_stub.py @@ -57,11 +57,28 @@ def get_function_node_name(node: ast.FunctionDef) -> Union[SubnameType, None]: :return: Function node name as ``SubnameType`` or ``None``, if function wasn't processed and can be skipped. """ node_name = node.name - if node_name.startswith("_") and node_name not in ("_is",): + is_init = node_name == "__init__" + + if node_name.startswith("_") and node_name not in ("_is",) and not is_init: + return None + arg_nodes = node.args.args + defaults = [None] * (len(arg_nodes) - len(node.args.defaults)) + node.args.defaults + args: list[str] = [] + for arg, default in zip(arg_nodes, defaults): + if default is None: + args.append(arg.arg) + else: + args.append(f"{arg.arg}={ast.unparse(default)}") + + if arg := node.args.vararg: + args.append(f"*{arg.arg}") + + if arg := node.args.kwarg: + args.append(f"**{arg.arg}") + + # Skip non-informative constructors. + if is_init and args == ["self"]: return None - args = [a.arg for a in node.args.args] - if node.args.vararg: - args.append("*args") node_name = f"def {node.name}" node_name = f"{node_name}({', '.join(args)}): ..." diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index c6ee820ab8..bbe8125927 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -440,13 +440,13 @@ def _get_element_value(element: ifcopenshell.entity_instance, keys: list[str]) - elif key == "container": value = ifcopenshell.util.element.get_container(value) elif key == "space": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcSpace") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcSpace") elif key == "storey": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcBuildingStorey") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcBuildingStorey") elif key == "building": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcBuilding") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcBuilding") elif key == "site": - value = ifcopenshell.util.element.get_container(value, ifc_class="IfcSite") + value = ifcopenshell.util.element.get_parent(value, ifc_class="IfcSite") elif key == "parent": value = ifcopenshell.util.element.get_parent(value) elif key in ("types", "occurrences"): diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 907cb85b5a..e53d069a76 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -40,7 +40,7 @@ if TYPE_CHECKING: # NOTE: mathutils is never used at runtime in ifcopenshell, # only for type checking to ensure methods are compatible with # Blender vectors. - from mathutils import Vector # pyright: ignore[reportMissingImports] + from mathutils import Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Support both numpy arrays and python sequences as inputs. VectorType = Union[Sequence[float], Vector, np.ndarray] @@ -517,11 +517,18 @@ class ShapeBuilder: trim_points_mask: Sequence[int], position_offset: Optional[VectorType] = None, ) -> np.ndarray: - """Handy way to get edge points of the ellipse like shape of a given radiuses. + """Get cardinal-point coordinates of an ellipse by index mask. - Mask points are numerated from 0 to 3 ccw starting from (x_axis_radius/2; 0). + The four cardinal points are numbered 0–3 counter-clockwise starting from the + positive X axis: 0 → ``(x, 0)``, 1 → ``(0, y)``, 2 → ``(-x, 0)``, 3 → ``(0, -y)``. - Example: mask (0, 1, 2, 3) will return points (x, 0), (0, y), (-x, 0), (0, -y) + Example: mask ``(0, 1, 2, 3)`` returns all four points in order. + + :param x_axis_radius: Radius (semi-axis length) along the X axis. + :param y_axis_radius: Radius (semi-axis length) along the Y axis. + :param trim_points_mask: Sequence of cardinal-point indices (0–3) to select. + :param position_offset: Optional 2D offset added to all returned points. + :return: Numpy array of the selected 2D points. """ points = np.array( ( @@ -546,15 +553,23 @@ class ShapeBuilder: ref_x_direction: VectorType = (1.0, 0.0), trim_points_mask: Sequence[int] = (), ) -> ifcopenshell.entity_instance: - """ - Ellipse trimming points should be specified in counter clockwise order. + """Create an IfcEllipse, optionally trimmed to an arc. - For example, if you need to get the part of the ellipse ABOVE y-axis, you need to use mask (0,2). Below y-axis - (2,0) + If neither ``trim_points`` nor ``trim_points_mask`` is provided, a full IfcEllipse is returned. + Trimming points must be given in counter-clockwise order. For example, to get the arc + above the Y-axis use mask ``(0, 2)``; below the Y-axis use ``(2, 0)``. - For more information about trim_points_mask check builder.get_trim_points_from_mask + A trimmed result (IfcTrimmedCurve) includes a closing segment between the trim points, + making it suitable for use as a profile in :meth:`extrude`. - Notion: trimmed ellipse also contains polyline between trim points, meaning IfcTrimmedCurve could be used - for further extrusion. + :param x_axis_radius: Semi-axis length along the local X axis. + :param y_axis_radius: Semi-axis length along the local Y axis. + :param position: 2D centre of the ellipse. + :param trim_points: Explicit pair of 2D trim points. Takes precedence over ``trim_points_mask``. + :param ref_x_direction: Direction of the local X axis. + :param trim_points_mask: Pair of cardinal-point indices (0–3) used when ``trim_points`` is empty. + See :meth:`get_trim_points_from_mask` for index definitions. + :return: IfcEllipse (untrimmed) or IfcTrimmedCurve (trimmed). """ ifc_position = self.create_axis2_placement_2d(position, ref_x_direction) ifc_ellipse = self.file.createIfcEllipse( @@ -685,6 +700,14 @@ class ShapeBuilder: pivot_point: VectorType = (0.0, 0.0), counter_clockwise: bool = False, ) -> np.ndarray: + """Rotate a single 2D point around a pivot. + + :param point_2d: The 2D point to rotate. + :param angle: Rotation angle, in degrees. Defaults to 90. + :param pivot_point: The point to rotate around. + :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise. + :return: Rotated 2D point as a numpy array. + """ angle_rad = radians(angle) * (1 if counter_clockwise else -1) relative_point = np.array(point_2d) - pivot_point relative_point = np_rotation_matrix(angle_rad, 2) @ relative_point @@ -752,7 +775,16 @@ class ShapeBuilder: mirror_axes: VectorType = (1.0, 1.0), mirror_point: VectorType = (0.0, 0.0), ) -> np.ndarray: - """mirror_axes - along which axes mirror will be applied""" + """Mirror a single 2D point across the specified axes. + + :param point_2d: The 2D point to mirror. + :param mirror_axes: Indicates which axes to mirror across. A positive value in a + component means that axis is mirrored (negated relative to ``mirror_point``). + Example: ``(1, 0)`` mirrors across the Y-axis (negates X only), + ``(1, 1)`` mirrors across both axes. + :param mirror_point: Origin of the mirror operation. + :return: Mirrored 2D point as a numpy array. + """ mirror_axes: np.ndarray = np.where(np.array(mirror_axes) > 0, -1, 1) mirror_point: np.ndarray = np.array(mirror_point) relative_point = point_2d - mirror_point @@ -798,7 +830,13 @@ class ShapeBuilder: def create_axis2_placement_2d( self, position: VectorType = (0.0, 0.0), x_direction: Optional[VectorType] = None ) -> ifcopenshell.entity_instance: - """Create IfcAxis2Placement2D.""" + """Create IfcAxis2Placement2D. + + :param position: 2D origin of the placement. + :param x_direction: Direction of the local X axis. If not provided, defaults to + the global X axis ``(1, 0)``. + :return: IfcAxis2Placement2D + """ ref_direction = ( self.file.create_entity("IfcDirection", ifc_safe_vector_type(x_direction)) if x_direction else None ) @@ -1000,7 +1038,7 @@ class ShapeBuilder: ) -> ifcopenshell.entity_instance: """ :param plane: The IfcPlane representing the half space. - :param agreement_flag: False if +Z represents the void + :param agreement_flag: If False (default), the plane normal points toward the **removed** material (the void). The kept region is on the opposite side from the normal. :return: IfcHalfSpaceSolid """ return self.file.createIfcHalfSpaceSolid(plane, AgreementFlag=agreement_flag) @@ -1053,7 +1091,14 @@ class ShapeBuilder: def create_swept_disk_solid( self, path_curve: ifcopenshell.entity_instance, radius: float ) -> ifcopenshell.entity_instance: - """Create IfcSweptDiskSolid from `path_curve` (must be 3D) and `radius`""" + """Create an IfcSweptDiskSolid — a circular cross-section swept along a 3D path. + + Useful for modelling round pipes, conduits, and cables. + + :param path_curve: A 3D curve entity defining the centreline path. Must have ``Dim == 3``. + :param radius: Radius of the circular disk cross-section. + :return: IfcSweptDiskSolid + """ if path_curve.Dim != 3: raise Exception( f"Path curve for IfcSweptDiskSolid should be 3D to be valid, currently it has {path_curve.Dim} dimensions.\n" @@ -1071,10 +1116,22 @@ class ShapeBuilder: ) -> ifcopenshell.entity_instance: """Create IFC representation for the specified context and items. + **All items must belong to the same geometry category.** IFC prohibits + mixing incompatible item types in one representation (e.g. + ``IfcExtrudedAreaSolid`` with ``IfcBlock``, or solids with curves). + When ``representation_type`` is omitted the type is inferred via + :func:`ifcopenshell.util.representation.guess_type`; if the items are + heterogeneous ``guess_type`` returns ``None`` and the representation is + written with no ``RepresentationType``, which fails IFC validation. + Avoid mixing swept-solid primitives (``IfcExtrudedAreaSolid``, + ``IfcRevolvedAreaSolid``) with CSG primitives (``IfcBlock``, + ``IfcSphere``, etc.) or any other category in a single call. + :param context: IfcGeometricRepresentationSubContext - :param items: could be a list or single curve/IfcExtrudedAreaSolid + :param items: A single item or list of items, all of the same geometry + category (e.g. all ``IfcExtrudedAreaSolid``, all ``IfcIndexedPolyCurve``) :param representation_type: Explicitly specified RepresentationType. - If not provided it will be guessed from the items types + If not provided it will be guessed from the items types. :return: IfcShapeRepresentation """ if not isinstance(items, collections.abc.Iterable): @@ -1096,18 +1153,26 @@ class ShapeBuilder: ) def deep_copy(self, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + """Create a deep copy of an IFC element and all its referenced entities. + + :param element: The IFC entity to copy. + :return: A new independent copy of the element. + """ return ifcopenshell.util.element.copy_deep(self.file, element) # UTILITIES def extrude_kwargs(self, axis: Literal["Y", "X", "Z"]) -> dict[str, tuple[float, float, float]]: - """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis. + """Shortcut to get kwargs for :meth:`extrude` to extrude along a principal axis. - It assumes you have 2D profile in: - XZ plane for Y axis extrusion, \n - YZ plane for X axis extrusion, \n - XY plane for Z axis extrusion, \n + Assumes the 2D profile lies in the plane perpendicular to the extrusion axis: + XZ plane for Y-axis extrusion, YZ plane for X-axis extrusion, XY plane for Z-axis extrusion. - Extruding by X/Y using other kwargs might break ValidExtrusionDirection.""" + Extruding along X or Y with other kwargs may violate the IFC ValidExtrusionDirection constraint. + + :param axis: The extrusion axis: ``'X'``, ``'Y'``, or ``'Z'``. + :return: A dict with keys ``position_x_axis``, ``position_z_axis``, and ``extrusion_vector`` + suitable for passing as ``**kwargs`` to :meth:`extrude`. + """ if axis == "Y": return { @@ -1131,13 +1196,16 @@ class ShapeBuilder: def rotate_extrusion_kwargs_by_z( self, kwargs: dict[str, Any], angle: float, counter_clockwise: bool = False ) -> dict[str, VectorType]: - """shortcut to rotate extrusion kwargs by z axis + """Rotate extrusion kwargs around the Z axis. - `kwargs` expected to have `position_x_axis` and `position_z_axis` keys + A shortcut to rotate the ``position_x_axis`` and ``position_z_axis`` values returned by + :meth:`extrude_kwargs` around the Z axis before passing them to :meth:`extrude`. - `angle` is a rotation value in radians - - by default rotation is clockwise, to make it counter clockwise use `counter_clockwise` flag + :param kwargs: A dict with ``position_x_axis`` and ``position_z_axis`` keys, + as returned by :meth:`extrude_kwargs`. The original dict is not mutated. + :param angle: Rotation angle, in radians. + :param counter_clockwise: If True, rotate counter-clockwise. Defaults to clockwise. + :return: A new dict with ``position_x_axis`` and ``position_z_axis`` rotated around Z. """ rot = np_rotation_matrix(-angle, 3, "Z") kwargs = kwargs.copy() # prevent mutation of original kwargs @@ -1146,7 +1214,11 @@ class ShapeBuilder: return kwargs def get_polyline_coords(self, polyline: ifcopenshell.entity_instance) -> np.ndarray: - """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`""" + """Extract the coordinate array from a polyline entity. + + :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity. + :return: Numpy array of the polyline's point coordinates. + """ coords = None if polyline.is_a("IfcIndexedPolyCurve"): coords = np.array(polyline.Points.CoordList) @@ -1157,7 +1229,12 @@ class ShapeBuilder: return coords def set_polyline_coords(self, polyline: ifcopenshell.entity_instance, coords: SequenceOfVectors) -> None: - """polyline should be either `IfcIndexedPolyCurve` or `IfcPolyline`""" + """Update the coordinates of a polyline entity in-place. + + :param polyline: An ``IfcIndexedPolyCurve`` or ``IfcPolyline`` entity. + :param coords: New sequence of point coordinates. Must contain the same number of + points as the original polyline. + """ if polyline.is_a("IfcIndexedPolyCurve"): polyline.Points.CoordList = ifc_safe_vector_type(coords) elif polyline.is_a("IfcPolyline"): @@ -1296,6 +1373,18 @@ class ShapeBuilder: WallThickness: float, FilletRadius: float, ) -> ifcopenshell.entity_instance: + """Create a Z-profile (cold-formed steel section) outline curve with lips and fillets. + + All dimensions are in the IFC project's length units. + + :param FirstFlangeWidth: Width of the first (top) flange, measured from the web centreline. + :param SecondFlangeWidth: Width of the second (bottom) flange, measured from the web centreline. + :param Depth: Total depth of the section (web height). + :param Girth: Length of the return lips on each flange. + :param WallThickness: Uniform material thickness. + :param FilletRadius: Inner bend radius at each corner. + :return: IfcIndexedPolyCurve representing the closed Z-profile outline. + """ x1 = FirstFlangeWidth x2 = SecondFlangeWidth y = Depth / 2 @@ -1337,10 +1426,17 @@ class ShapeBuilder: def create_transition_arc_ifc( self, width: float, height: float, create_ifc_curve: bool = False ) -> tuple[SequenceOfVectors, list[list[int]], Union[ifcopenshell.entity_instance, None]]: - """Create an arc in the rectangle with specified width and height. + """Create an arc fitting inside a rectangle of the given width and height. - If it's not possible to make a complete arc, create an arc with longest radius possible - and straight segment in the middle. + If a single arc cannot span the full width, the longest possible radius is used and + a straight segment is inserted in the middle. + + :param width: Width of the bounding rectangle. + :param height: Height of the bounding rectangle (also the maximum arc radius). + :param create_ifc_curve: If True, also create and return an ``IfcIndexedPolyCurve``. + If False, only return the raw point and segment data. + :return: A tuple ``(points, segments, ifc_curve)`` where ``ifc_curve`` is an + ``IfcIndexedPolyCurve`` when ``create_ifc_curve=True``, otherwise ``None``. """ fillet_size = (width / 2) / height if fillet_size <= 1: @@ -1370,6 +1466,14 @@ class ShapeBuilder: return points, segments, transition_arc def mesh(self, points: SequenceOfVectors, faces: Sequence[Sequence[int]]) -> ifcopenshell.entity_instance: + """Create a tessellated mesh from points and face indices. + + Delegates to :meth:`faceted_brep` for IFC2X3, or :meth:`polygonal_face_set` for IFC4 and later. + + :param points: List of 3D coordinates. + :param faces: List of faces, each face a sequence of zero-based point indices. + :return: IfcFacetedBrep (IFC2X3) or IfcPolygonalFaceSet (IFC4+). + """ if self.file.schema == "IFC2X3": return self.faceted_brep(points, faces) return self.polygonal_face_set(points, faces) @@ -1723,11 +1827,20 @@ class ShapeBuilder: angle: float, profile_offset: VectorType = (0.0, 0.0), verbose: bool = True, - ): - """get the final transition length for two profiles dimensions, angle and XY offset between them, + ) -> Optional[float]: + """Get the transition length for two profile half-dimensions, an angle, and an XY offset. - the difference from `calculate_transition` - `get_transition_length` is making sure - that length will fit both sides of the transition + Unlike :meth:`mep_transition_calculate`, this method checks that the resulting length + satisfies the angle constraint from both the start and end profile perspectives. + + :param start_half_dim: Half-dimensions of the start profile as a 3-element array + ``[half_x, half_y, depth]``. For circular profiles ``half_x == half_y == radius``. + :param end_half_dim: Half-dimensions of the end profile in the same format. + :param angle: Maximum allowed transition angle, in degrees. + :param profile_offset: 2D XY offset between the centrelines of the start and end profiles. + :param verbose: If True, print diagnostic values during calculation. + :return: Transition length in project length units, or ``None`` if no valid length exists + for the given angle and offset. """ print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None np_X, np_Y = 0, 1 @@ -1788,9 +1901,23 @@ class ShapeBuilder: angle: Optional[float] = None, verbose: bool = True, ) -> Union[float, None]: - """will return transition length based on the profile dimension differences and offset. + """Calculate MEP transition length from angle, or transition angle from length. - If `length` is provided will return transition angle""" + Low-level calculation kernel used by :meth:`mep_transition_length`. Provide either + ``angle`` or ``length`` (not both); the other value is computed and returned. + + :param start_half_dim: Half-dimensions of the start profile ``[half_x, half_y, depth]``. + :param end_half_dim: Half-dimensions of the end profile ``[half_x, half_y, depth]``. + :param offset: 2D XY offset between profile centrelines. + :param diff: Pre-computed absolute difference of start and end half-dimensions (XY only). + Computed from ``start_half_dim`` and ``end_half_dim`` if not provided. + :param end_profile: If True, swap X and Y axes to compute from the end-profile perspective. + :param length: Known transition length. If provided, the corresponding angle is returned. + :param angle: Known transition angle, in degrees. If provided, the corresponding length is returned. + :param verbose: If True, print diagnostic values during calculation. + :return: Transition length (if ``angle`` was given) or transition angle in degrees + (if ``length`` was given), or ``None`` if the geometry is not feasible. + """ print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index a98282fd1c..524dc78d35 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -47,6 +47,7 @@ from __future__ import annotations import argparse import functools +import itertools import json import os import sys @@ -331,7 +332,7 @@ def log_internal_cpp_errors( lines = list(open(filename, "rb")) lengths = list(map(len, lines)) cumsum = 0 - cs = [cumsum := cumsum + x for x in lengths] + cs = list(itertools.accumulate(lengths)) for offsets, msg in zip(chr_offsets, msgs): if offsets: diff --git a/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py new file mode 100644 index 0000000000..ed79d7e8bf --- /dev/null +++ b/src/ifcopenshell-python/test/api/boundary/test_edit_attributes.py @@ -0,0 +1,115 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.boundary +import ifcopenshell.api.root +import test.bootstrap + + +class TestEditAttributes(test.bootstrap.IFC4): + def setup_boundary(self): + space = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcSpace") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + boundary = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcRelSpaceBoundary") + return boundary, space, wall + + def test_sets_relating_space_and_building_element(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.RelatingSpace == space + assert boundary.RelatedBuildingElement == wall + + def test_defaults_enums_to_notdefined(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, entity=boundary, relating_space=space, related_building_element=wall + ) + assert boundary.PhysicalOrVirtualBoundary == "NOTDEFINED" + assert boundary.InternalOrExternalBoundary == "NOTDEFINED" + + def test_sets_physical_or_virtual(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual="PHYSICAL", + ) + assert boundary.PhysicalOrVirtualBoundary == "PHYSICAL" + + def test_sets_internal_or_external(self): + boundary, space, wall = self.setup_boundary() + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external="EXTERNAL", + ) + assert boundary.InternalOrExternalBoundary == "EXTERNAL" + + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + for value in ("INTERNAL", "EXTERNAL", "EXTERNAL_EARTH", "EXTERNAL_WATER", "EXTERNAL_FIRE", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value + + +class TestEditAttributesIFC2X3(test.bootstrap.IFC2X3, TestEditAttributes): + def test_sets_all_enum_variants(self): + boundary, space, wall = self.setup_boundary() + for value in ("PHYSICAL", "VIRTUAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + physical_or_virtual=value, + ) + assert boundary.PhysicalOrVirtualBoundary == value + + # IFC2X3 only has INTERNAL, EXTERNAL, NOTDEFINED + for value in ("INTERNAL", "EXTERNAL", "NOTDEFINED"): + ifcopenshell.api.boundary.edit_attributes( + self.file, + entity=boundary, + relating_space=space, + related_building_element=wall, + internal_or_external=value, + ) + assert boundary.InternalOrExternalBoundary == value diff --git a/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py new file mode 100644 index 0000000000..38b6775c08 --- /dev/null +++ b/src/ifcopenshell-python/test/api/cost/test_edit_cost_value.py @@ -0,0 +1,70 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.cost +import ifcopenshell.api.unit +import test.bootstrap + + +class TestEditCostValue(test.bootstrap.IFC4): + def test_editing_applied_value(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + ifcopenshell.api.cost.edit_cost_value(self.file, cost_value=value, attributes={"AppliedValue": 42.0}) + assert value.AppliedValue.wrappedValue == 42.0 + + def test_editing_unit_basis_removes_old_deeply(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 1.0, "UnitComponent": unit}}, + ) + old_basis = value.UnitBasis + assert old_basis is not None + old_basis_id = old_basis.id() + # Now change to a new unit basis — the old one should be deeply removed. + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 2.0, "UnitComponent": unit}}, + ) + assert value.UnitBasis is not None + assert value.UnitBasis.id() != old_basis_id + + def test_clearing_unit_basis(self): + schedule = ifcopenshell.api.cost.add_cost_schedule(self.file) + item = ifcopenshell.api.cost.add_cost_item(self.file, cost_schedule=schedule) + value = ifcopenshell.api.cost.add_cost_value(self.file, parent=item) + unit = ifcopenshell.api.unit.add_si_unit(self.file, unit_type="LENGTHUNIT") + ifcopenshell.api.cost.edit_cost_value( + self.file, + cost_value=value, + attributes={"UnitBasis": {"ValueComponent": 1.0, "UnitComponent": unit}}, + ) + assert value.UnitBasis is not None + ifcopenshell.api.cost.edit_cost_value(self.file, cost_value=value, attributes={"UnitBasis": None}) + assert value.UnitBasis is None + + +class TestEditCostValueIFC4X3(test.bootstrap.IFC4X3, TestEditCostValue): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py index d507f89308..f750795ee7 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py +++ b/src/ifcopenshell-python/test/api/geometry/test_add_boolean.py @@ -42,7 +42,7 @@ class TestAddBoolean(test.bootstrap.IFC4): assert boolean.FirstOperand == first assert boolean.SecondOperand == second assert boolean.Operator == "DIFFERENCE" - assert set(rep.Items) == {boolean} + assert set(rep.Items) == {boolean, second} def test_adding_multiple_booleans_from_three_top_level_items(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -58,13 +58,14 @@ class TestAddBoolean(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1, second2]) assert len(booleans) == 2 - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") - assert rep.Items[0].SecondOperand == second2 - assert rep.Items[0].Operator == "DIFFERENCE" - assert rep.Items[0].FirstOperand.FirstOperand == first - assert rep.Items[0].FirstOperand.SecondOperand == second1 - assert rep.Items[0].FirstOperand.Operator == "DIFFERENCE" + final_boolean = booleans[-1] + assert final_boolean.FirstOperand.is_a("IfcBooleanResult") + assert final_boolean.SecondOperand == second2 + assert final_boolean.Operator == "DIFFERENCE" + assert final_boolean.FirstOperand.FirstOperand == first + assert final_boolean.FirstOperand.SecondOperand == second1 + assert final_boolean.FirstOperand.Operator == "DIFFERENCE" + assert set(rep.Items) == {final_boolean, second1, second2} def test_adding_a_boolean_to_an_existing_operand_from_a_top_level_item(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -78,14 +79,16 @@ class TestAddBoolean(test.bootstrap.IFC4): second2 = builder.block() rep = builder.get_representation(body, [first, second1]) booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) + # second1 stays in Items, add second2 as well rep.Items = list(rep.Items) + [second2] booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second2]) assert len(booleans) == 1 - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand.is_a("IfcBooleanResult") - assert rep.Items[0].SecondOperand == second2 - assert rep.Items[0].FirstOperand.FirstOperand == first - assert rep.Items[0].FirstOperand.SecondOperand == second1 + final_boolean = booleans[0] + assert final_boolean.FirstOperand.is_a("IfcBooleanResult") + assert final_boolean.SecondOperand == second2 + assert final_boolean.FirstOperand.FirstOperand == first + assert final_boolean.FirstOperand.SecondOperand == second1 + assert set(rep.Items) == {final_boolean, second1, second2} def test_adding_a_boolean_to_an_existing_operand_from_another_operand(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") @@ -104,7 +107,7 @@ class TestAddBoolean(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first1, [second2]) assert len(booleans) == 1 - assert len(rep.Items) == 2 + assert len(rep.Items) == 4 assert self.file.get_total_inverses(first1) == 1 result = next(iter(self.file.get_inverse(first1))) @@ -132,14 +135,15 @@ class TestAddBoolean(test.bootstrap.IFC4): rep = builder.get_representation(body, [first, second]) ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) ifcopenshell.api.geometry.add_boolean(self.file, first, [second]) - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand == first - assert rep.Items[0].SecondOperand == second + assert set(rep.Items) == {self.file.by_type("IfcBooleanResult")[0], second} + boolean = self.file.by_type("IfcBooleanResult")[0] + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second ifcopenshell.api.geometry.add_boolean(self.file, second, [second]) ifcopenshell.api.geometry.add_boolean(self.file, second, [first]) - assert len(rep.Items) == 1 - assert rep.Items[0].FirstOperand == first - assert rep.Items[0].SecondOperand == second + assert set(rep.Items) == {boolean, second} + assert boolean.FirstOperand == first + assert boolean.SecondOperand == second assert len(self.file.by_type("IfcBooleanResult")) == 1 diff --git a/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py new file mode 100644 index 0000000000..5b89f50338 --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_add_topology_representation.py @@ -0,0 +1,82 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import test.bootstrap + + +class TestAddTopologyRepresentation(test.bootstrap.IFC4): + def setup_context(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + model = ifcopenshell.api.context.add_context(self.file, context_type="Model") + return ifcopenshell.api.context.add_context( + self.file, + context_type="Model", + context_identifier="Reference", + target_view="GRAPH_VIEW", + parent=model, + ) + + def test_creates_topology_representation(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.is_a("IfcTopologyRepresentation") + assert rep.ContextOfItems == context + assert face in rep.Items + + def test_infers_face_representation_type(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.RepresentationType == "Face" + + def test_infers_edge_representation_type(self): + context = self.setup_context() + edge = self.file.create_entity("IfcEdge") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=edge) + assert rep.RepresentationType == "Edge" + + def test_defaults_representation_identifier_to_context_identifier(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation(self.file, context=context, item=face) + assert rep.RepresentationIdentifier == context.ContextIdentifier + + def test_custom_representation_identifier(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation( + self.file, context=context, item=face, representation_identifier="Body" + ) + assert rep.RepresentationIdentifier == "Body" + + def test_custom_representation_type_overrides_inferred(self): + context = self.setup_context() + face = self.file.create_entity("IfcFaceSurface") + rep = ifcopenshell.api.geometry.add_topology_representation( + self.file, context=context, item=face, representation_type="Undefined" + ) + assert rep.RepresentationType == "Undefined" + + +class TestAddTopologyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestAddTopologyRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py new file mode 100644 index 0000000000..512bfcc92e --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid.py @@ -0,0 +1,154 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestClipSolid(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(1.0, 1.0)) + return builder.extrude(rect, magnitude=4.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.Operator == "DIFFERENCE" + + def test_first_operand_is_the_item(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_half_space_solid(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [0.0, 0.0, 3.0] + + def test_chaining_two_clips(self): + extrusion = self.make_extrusion() + first_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + second_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first_clip, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + ) + assert second_clip.is_a("IfcBooleanClippingResult") + assert second_clip.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + def test_angled_clip_plane(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.26], + normal=[0.419, 0.0, 0.908], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.SecondOperand.is_a("IfcHalfSpaceSolid") + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + result = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_element_appends_to_existing_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + first = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + element=wall, + ) + second = ifcopenshell.api.geometry.clip_solid( + self.file, + item=first, + location=[0.0, 0.0, 1.0], + normal=[0.0, 0.0, -1.0], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + ids = json.loads(pset["Data"]) + assert first.id() in ids + assert second.id() in ids + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidIFC2X3(test.bootstrap.IFC2X3, TestClipSolid): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py new file mode 100644 index 0000000000..3dae335cad --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_clip_solid_bounded.py @@ -0,0 +1,179 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import json + +import ifcopenshell.api.geometry +import ifcopenshell.util.element +import ifcopenshell.util.shape_builder +import test.bootstrap + + +class TestClipSolidBounded(test.bootstrap.IFC4): + def make_extrusion(self): + builder = ifcopenshell.util.shape_builder.ShapeBuilder(self.file) + rect = builder.rectangle(size=(4.0, 1.0)) + return builder.extrude(rect, magnitude=3.0) + + def test_returns_boolean_clipping_result(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.Operator == "DIFFERENCE" + + def test_first_operand_is_the_item(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.FirstOperand == extrusion + + def test_second_operand_is_polygonal_bounded_half_space(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.is_a("IfcPolygonalBoundedHalfSpace") + + def test_agreement_flag_is_false(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.SecondOperand.AgreementFlag is False + + def test_clip_plane_location_matches(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + plane = result.SecondOperand.BaseSurface + coords = plane.Position.Location.Coordinates + assert list(coords) == [2.5, 0.0, 2.0] + + def test_boundary_is_closed_polyline(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + boundary = result.SecondOperand.PolygonalBoundary + assert boundary.is_a("IfcPolyline") + pts = [list(p.Coordinates) for p in boundary.Points] + assert pts[0] == pts[-1], "polygon should be closed" + assert len(pts) == 5 # 4 unique + closing repeat + + def test_boundary_position_defaults_to_origin(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [0.0, 0.0, 0.0] + + def test_custom_boundary_position(self): + extrusion = self.make_extrusion() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + boundary_position=[1.0, 2.0, 3.0], + ) + pos = result.SecondOperand.Position + assert list(pos.Location.Coordinates) == [1.0, 2.0, 3.0] + + def test_chaining_with_clip_solid(self): + extrusion = self.make_extrusion() + first_clip = ifcopenshell.api.geometry.clip_solid( + self.file, + item=extrusion, + location=[0.0, 0.0, 3.0], + normal=[0.0, 0.0, 1.0], + ) + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=first_clip, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert result.is_a("IfcBooleanClippingResult") + assert result.FirstOperand == first_clip + assert first_clip.FirstOperand == extrusion + + def test_element_registers_result_in_bbim_boolean(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + result = ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + element=wall, + ) + pset = ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") + assert pset is not None + assert result.id() in json.loads(pset["Data"]) + + def test_no_element_does_not_create_pset(self): + extrusion = self.make_extrusion() + wall = self.file.createIfcWall() + ifcopenshell.api.geometry.clip_solid_bounded( + self.file, + item=extrusion, + location=[2.5, 0.0, 2.0], + normal=[0.6, 0.0, 0.8], + boundary_points=[[2.0, 0.0], [3.0, 0.0], [3.0, 2.0], [2.0, 2.0]], + ) + assert ifcopenshell.util.element.get_pset(wall, "BBIM_Boolean") is None + + +class TestClipSolidBoundedIFC2X3(test.bootstrap.IFC2X3, TestClipSolidBounded): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py index bda341d2c6..0927e474ae 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_connect_path.py +++ b/src/ifcopenshell-python/test/api/geometry/test_connect_path.py @@ -33,6 +33,18 @@ class TestConnectPath(test.bootstrap.IFC4): assert rel.RelatedConnectionType == "ATEND" assert rel.Description == "MITRE" + def test_storing_connection_geometry(self): + wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + geometry = self.file.create_entity("IfcConnectionPointGeometry") + rel = ifcopenshell.api.geometry.connect_path( + self.file, + relating_element=wall1, + related_element=wall2, + connection_geometry=geometry, + ) + assert rel.ConnectionGeometry == geometry + def test_doing_nothing_if_the_element_is_already_connected(self): wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py new file mode 100644 index 0000000000..b997a91bcf --- /dev/null +++ b/src/ifcopenshell-python/test/api/geometry/test_copy_representation.py @@ -0,0 +1,133 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.geometry +import ifcopenshell.api.root +import ifcopenshell.util.representation +import test.bootstrap + + +class TestCopyRepresentation(test.bootstrap.IFC4): + def _body_context(self): + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") + if body is None: + model = self.file.createIfcGeometricRepresentationContext( + ContextType="Model", + CoordinateSpaceDimension=3, + Precision=1e-5, + WorldCoordinateSystem=self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + body = self.file.createIfcGeometricRepresentationSubContext( + ContextIdentifier="Body", + ContextType="Model", + TargetView="MODEL_VIEW", + ParentContext=model, + ) + return body + + def _add_body_rep(self, element): + body = self._body_context() + rep = ifcopenshell.api.geometry.add_wall_representation( + self.file, context=body, length=5.0, height=3.0, thickness=0.2 + ) + ifcopenshell.api.geometry.assign_representation(self.file, product=element, representation=rep) + return rep + + def test_copy_to_empty_target(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is not None + assert result.is_a("IfcShapeRepresentation") + target_rep = ifcopenshell.util.representation.get_representation(wall_b, "Model", "Body") + assert target_rep is not None + assert target_rep == result + + def test_source_rep_entities_are_distinct(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.id() != source_rep.id() + assert new_rep.Items[0].id() != source_rep.Items[0].id() + + def test_context_is_shared_not_copied(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + + new_rep = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert new_rep.ContextOfItems.id() == source_rep.ContextOfItems.id() + + def test_replaces_existing_target_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + old_rep = self._add_body_rep(wall_b) + old_rep_id = old_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + try: + self.file.by_id(old_rep_id) + assert False, "old representation still exists" + except RuntimeError: + pass # entity was removed, as expected + + def test_source_unchanged_after_copy(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + source_rep = self._add_body_rep(wall_a) + source_rep_id = source_rep.id() + + ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert self.file.by_id(source_rep_id) is not None # source must still exist + assert ifcopenshell.util.representation.get_representation(wall_a, "Model", "Body") is not None + + def test_returns_none_when_no_matching_rep(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + + result = ifcopenshell.api.geometry.copy_representation(self.file, source=wall_a, target=wall_b) + + assert result is None + + def test_custom_context_identifier(self): + wall_a = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall_b = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + self._add_body_rep(wall_a) + + # "Axis" doesn't exist on wall_a, so should return None + result = ifcopenshell.api.geometry.copy_representation( + self.file, source=wall_a, target=wall_b, context_identifier="Axis" + ) + + assert result is None + + +class TestCopyRepresentationIFC2X3(test.bootstrap.IFC2X3, TestCopyRepresentation): + pass diff --git a/src/ifcopenshell-python/test/api/geometry/test_validate_type.py b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py index 7f4a57e995..01fa17bfed 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_validate_type.py +++ b/src/ifcopenshell-python/test/api/geometry/test_validate_type.py @@ -76,7 +76,7 @@ class TestValidateType(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) assert len(booleans) == 1 - assert len(rep.Items) == 3 + assert len(rep.Items) == 4 assert ifcopenshell.api.geometry.validate_type(self.file, rep) is True assert len(rep.Items) == 1 assert rep.RepresentationType == "CSG" @@ -96,9 +96,9 @@ class TestValidateType(test.bootstrap.IFC4): booleans = ifcopenshell.api.geometry.add_boolean(self.file, first, [second1]) assert len(booleans) == 1 - assert len(rep.Items) == 2 + assert len(rep.Items) == 3 # boolean replaced first, but second1 stays in Items assert ifcopenshell.api.geometry.validate_type(self.file, rep) is False - assert len(rep.Items) == 2 + assert len(rep.Items) == 2 # validate_type unioned second1 into the boolean assert rep.RepresentationType is None diff --git a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py index 68f1ac2597..4eb0e4e609 100644 --- a/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py +++ b/src/ifcopenshell-python/test/api/georeference/test_add_georeferencing.py @@ -51,6 +51,38 @@ class TestAddGeoreferencing(test.bootstrap.IFC4): assert len(self.file.by_type("IfcMapConversion")) == 1 assert len(self.file.by_type("IfcProjectedCRS")) == 1 + def test_recovering_from_orphan_projected_crs(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + ifcopenshell.api.context.add_context(self.file, "Model") + self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234") + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + assert len(self.file.by_type("IfcCoordinateOperation")) == 0 + ifcopenshell.api.georeference.add_georeferencing(self.file) + assert len(self.file.by_type("IfcMapConversion")) == 1 + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + + def test_recovering_from_orphan_coordinate_operation(self): + ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") + context = ifcopenshell.api.context.add_context(self.file, "Model") + self.file.create_entity( + "IfcMapConversion", + SourceCRS=context, + TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"), + ) + ifcopenshell.api.georeference.remove_georeferencing(self.file) + # Simulate orphan by re-adding just a conversion without CRS + self.file.create_entity( + "IfcMapConversion", + SourceCRS=context, + TargetCRS=self.file.create_entity("IfcProjectedCRS", Name="EPSG:1234"), + ) + self.file.remove(self.file.by_type("IfcProjectedCRS")[0]) + assert len(self.file.by_type("IfcProjectedCRS")) == 0 + assert len(self.file.by_type("IfcCoordinateOperation")) == 1 + ifcopenshell.api.georeference.add_georeferencing(self.file) + assert len(self.file.by_type("IfcMapConversion")) == 1 + assert len(self.file.by_type("IfcProjectedCRS")) == 1 + class TestAddGeoreferencingIFC2X3(test.bootstrap.IFC2X3): def test_adding_georeferencing(self): diff --git a/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py b/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py new file mode 100644 index 0000000000..f59930e0e5 --- /dev/null +++ b/src/ifcopenshell-python/test/api/grid/test_remove_grid_axis.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.grid +import test.bootstrap + + +class TestRemoveGridAxis(test.bootstrap.IFC4): + def test_removing_an_axis_removes_its_curve(self): + grid = self.file.createIfcGrid() + axis = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="A", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis.AxisCurve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))]) + axis2 = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis2.AxisCurve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((1.0, 0.0, 0.0))]) + ifcopenshell.api.grid.remove_grid_axis(self.file, axis=axis2) + assert grid.UAxes == (axis,) + assert len(self.file.by_type("IfcGridAxis")) == 1 + # The curve should be removed since it was only used by the removed axis. + assert len(self.file.by_type("IfcPolyline")) == 1 + + def test_removing_an_axis_preserves_shared_curve(self): + grid = self.file.createIfcGrid() + shared_curve = self.file.createIfcPolyline([self.file.createIfcCartesianPoint((0.0, 0.0, 0.0))]) + axis = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="A", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis.AxisCurve = shared_curve + axis2 = ifcopenshell.api.grid.create_grid_axis( + self.file, axis_tag="B", same_sense=True, uvw_axes="UAxes", grid=grid + ) + axis2.AxisCurve = shared_curve + ifcopenshell.api.grid.remove_grid_axis(self.file, axis=axis2) + assert grid.UAxes == (axis,) + # The shared curve should be preserved since it's still used by axis. + assert shared_curve in self.file + assert axis.AxisCurve == shared_curve + + +class TestRemoveGridAxisIFC2X3(test.bootstrap.IFC2X3, TestRemoveGridAxis): + pass diff --git a/src/ifcopenshell-python/test/api/nest/test_assign_object.py b/src/ifcopenshell-python/test/api/nest/test_assign_object.py index 88f8d0bb90..c83f796ac0 100644 --- a/src/ifcopenshell-python/test/api/nest/test_assign_object.py +++ b/src/ifcopenshell-python/test/api/nest/test_assign_object.py @@ -18,8 +18,10 @@ import pytest +import ifcopenshell.api.aggregate import ifcopenshell.api.nest import ifcopenshell.api.root +import ifcopenshell.api.spatial import ifcopenshell.util.element import test.bootstrap @@ -82,6 +84,24 @@ class TestAssignObject(test.bootstrap.IFC4): ifcopenshell.api.nest.assign_object(self.file, related_objects=subelements[2:3], relating_object=element2) assert rel.RelatedObjects == tuple(subelements[:2] + subelements[3:]) + def test_nesting_removes_spatial_containment(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.spatial.assign_container(self.file, products=[subelement], relating_structure=storey) + assert ifcopenshell.util.element.get_container(subelement) == storey + ifcopenshell.api.nest.assign_object(self.file, related_objects=[subelement], relating_object=element) + assert ifcopenshell.util.element.get_container(subelement) is None + + def test_nesting_removes_aggregate(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + assembly = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcElementAssembly") + ifcopenshell.api.aggregate.assign_object(self.file, products=[subelement], relating_object=assembly) + assert ifcopenshell.util.element.get_aggregate(subelement) == assembly + ifcopenshell.api.nest.assign_object(self.file, related_objects=[subelement], relating_object=element) + assert ifcopenshell.util.element.get_aggregate(subelement) is None + class TestAssignObjectIFC2X3(test.bootstrap.IFC2X3, TestAssignObject): pass diff --git a/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py b/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py new file mode 100644 index 0000000000..d967a0017a --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset_template/test_remove_prop_template.py @@ -0,0 +1,38 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.pset_template +import test.bootstrap + + +class TestRemovePropTemplate(test.bootstrap.IFC4): + def test_removing_a_prop_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop1 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + prop2 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_prop_template(self.file, prop_template=prop2) + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 1 + assert template.HasPropertyTemplates == (prop1,) + + def test_not_removing_the_last_prop_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_prop_template(self.file, prop_template=prop) + # The last prop template should not be removed to keep the pset template valid. + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 1 + assert template.HasPropertyTemplates == (prop,) diff --git a/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py b/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py new file mode 100644 index 0000000000..67c700a74f --- /dev/null +++ b/src/ifcopenshell-python/test/api/pset_template/test_remove_pset_template.py @@ -0,0 +1,35 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.pset_template +import test.bootstrap + + +class TestRemovePsetTemplate(test.bootstrap.IFC4): + def test_removing_a_pset_template(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + ifcopenshell.api.pset_template.remove_pset_template(self.file, pset_template=template) + assert len(self.file.by_type("IfcPropertySetTemplate")) == 0 + + def test_removing_a_pset_template_with_property_templates(self): + template = ifcopenshell.api.pset_template.add_pset_template(self.file, name="ABC_RiskFactors") + prop1 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + prop2 = ifcopenshell.api.pset_template.add_prop_template(self.file, pset_template=template) + ifcopenshell.api.pset_template.remove_pset_template(self.file, pset_template=template) + assert len(self.file.by_type("IfcPropertySetTemplate")) == 0 + assert len(self.file.by_type("IfcSimplePropertyTemplate")) == 0 diff --git a/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py new file mode 100644 index 0000000000..74182d1c69 --- /dev/null +++ b/src/ifcopenshell-python/test/api/resource/test_remove_resource_quantity.py @@ -0,0 +1,42 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import ifcopenshell.api.resource +import test.bootstrap + + +class TestRemoveResourceQuantity(test.bootstrap.IFC4): + def test_removing_a_resource_quantity(self): + self.file.create_entity("IfcProject") + resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource") + ifcopenshell.api.resource.add_resource_quantity(self.file, resource=resource, ifc_class="IfcQuantityTime") + assert resource.BaseQuantity is not None + ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource) + assert resource.BaseQuantity is None + assert len(self.file.by_type("IfcPhysicalSimpleQuantity")) == 0 + + def test_removing_a_resource_quantity_when_none_exists(self): + self.file.create_entity("IfcProject") + resource = ifcopenshell.api.resource.add_resource(self.file, ifc_class="IfcLaborResource") + # Should not raise. + ifcopenshell.api.resource.remove_resource_quantity(self.file, resource=resource) + assert resource.BaseQuantity is None + + +class TestRemoveResourceQuantityIFC2X3(test.bootstrap.IFC2X3, TestRemoveResourceQuantity): + pass diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_product.py b/src/ifcopenshell-python/test/api/structural/test_assign_product.py new file mode 100644 index 0000000000..3e2f1c2b4a --- /dev/null +++ b/src/ifcopenshell-python/test/api/structural/test_assign_product.py @@ -0,0 +1,56 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.root +import ifcopenshell.api.structural +import test.bootstrap + + +class TestAssignProduct(test.bootstrap.IFC4): + def test_creating_a_new_relationship(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + rel = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + assert rel.is_a("IfcRelAssignsToProduct") + assert rel.RelatingProduct == member + assert wall in rel.RelatedObjects + + def test_adding_a_second_object_to_an_existing_relationship(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + wall2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + rel1 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall1) + rel2 = ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall2) + assert rel1 == rel2 + assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1 + assert wall1 in rel1.RelatedObjects + assert wall2 in rel1.RelatedObjects + + def test_does_not_duplicate_an_existing_assignment(self): + member = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcStructuralSurfaceMember") + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + ifcopenshell.api.structural.assign_product(self.file, relating_product=member, related_object=wall) + assert len(self.file.by_type("IfcRelAssignsToProduct")) == 1 + rels = self.file.by_type("IfcRelAssignsToProduct") + assert len(rels[0].RelatedObjects) == 1 + + +class TestAssignProductIFC2X3(test.bootstrap.IFC2X3, TestAssignProduct): + pass diff --git a/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py new file mode 100644 index 0000000000..671ca1b476 --- /dev/null +++ b/src/ifcopenshell-python/test/api/structural/test_assign_to_building.py @@ -0,0 +1,62 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2026 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . +# This file was generated with the assistance of an AI coding tool. + +import ifcopenshell.api.root +import ifcopenshell.api.structural +import test.bootstrap + + +class TestAssignToBuilding(test.bootstrap.IFC4): + def test_creating_a_new_relationship(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + rel = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building + ) + assert rel.is_a("IfcRelServicesBuildings") + assert rel.RelatingSystem == model + assert building in rel.RelatedBuildings + + def test_adding_a_second_building_to_an_existing_relationship(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building1 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + building2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + rel1 = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building1 + ) + rel2 = ifcopenshell.api.structural.assign_to_building( + self.file, structural_analysis_model=model, building=building2 + ) + assert rel1 == rel2 + assert len(self.file.by_type("IfcRelServicesBuildings")) == 1 + assert building1 in rel1.RelatedBuildings + assert building2 in rel1.RelatedBuildings + + def test_does_not_duplicate_an_existing_assignment(self): + model = ifcopenshell.api.structural.add_structural_analysis_model(self.file) + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building) + ifcopenshell.api.structural.assign_to_building(self.file, structural_analysis_model=model, building=building) + assert len(self.file.by_type("IfcRelServicesBuildings")) == 1 + rels = self.file.by_type("IfcRelServicesBuildings") + assert len(rels[0].RelatedBuildings) == 1 + + +class TestAssignToBuildingIFC2X3(test.bootstrap.IFC2X3, TestAssignToBuilding): + pass diff --git a/src/ifcopenshell-python/test/api/test_api.py b/src/ifcopenshell-python/test/api/test_api.py index 7036f93528..8bed51a56f 100644 --- a/src/ifcopenshell-python/test/api/test_api.py +++ b/src/ifcopenshell-python/test/api/test_api.py @@ -15,38 +15,3 @@ # # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . - -from datetime import datetime - -import ifcopenshell.api.control -import ifcopenshell.api.cost -import ifcopenshell.api.root -import ifcopenshell.util.element -import test.bootstrap - - -def deprecation_check(test): - def new_test(self): - assert datetime.now().date() < datetime(2026, 1, 9).date(), "API arguments are completely deprecated" - test(self) - - return new_test - - -class TestTemporarySupportForDeprecatedAPIArguments(test.bootstrap.IFC4): - @deprecation_check - def test_assigning_control(self): - model = self.file - element = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall") - control = ifcopenshell.api.cost.add_cost_schedule(model) - ifcopenshell.api.control.assign_control(model, relating_control=control, related_objects=[element]) - assert list(ifcopenshell.util.element.get_controls(element)) == [control] - - @deprecation_check - def test_unassigning_control(self): - TestTemporarySupportForDeprecatedAPIArguments.test_assigning_control(self) - model = self.file - element = model.by_type("IfcWall")[0] - control = model.by_type("IfcCostSchedule")[0] - ifcopenshell.api.control.unassign_control(model, relating_control=control, related_objects=[element]) - assert list(ifcopenshell.util.element.get_controls(element)) == [] diff --git a/src/ifcopenshell-python/test/test_express_aggregate_bounds.py b/src/ifcopenshell-python/test/test_express_aggregate_bounds.py new file mode 100644 index 0000000000..24b81fda5b --- /dev/null +++ b/src/ifcopenshell-python/test/test_express_aggregate_bounds.py @@ -0,0 +1,74 @@ +import os +import sys +import tempfile +import unittest + +import ifcopenshell.express + +sys.path.insert(0, os.path.dirname(ifcopenshell.express.__file__)) + + +def _parse(schema_text): + with tempfile.NamedTemporaryFile(mode="w", suffix=".exp", delete=False) as f: + f.write(schema_text) + path = f.name + try: + return ifcopenshell.express.parse(path) + finally: + os.unlink(path) + cache = path + ".cache.dat" + if os.path.exists(cache): + os.unlink(cache) + + +class TestAggregateBounds(unittest.TestCase): + def test_literal_bounds_preserved(self): + """After loading [1;3] -> (1, 3)?""" + s = _parse("SCHEMA t; ENTITY E; v : ARRAY [1:3] OF REAL; END_ENTITY; END_SCHEMA;") + agg = ( + next(d for d in s.schema.declarations() if d.name() == "E") + .attributes()[0] + .type_of_attribute() + .as_aggregation_type() + ) + self.assertEqual((agg.bound1(), agg.bound2()), (1, 3)) + s.disown() + + def test_unbounded_marker(self): + """[0:?] -> (0, -1)?""" + s = _parse("SCHEMA t; ENTITY E; v : LIST [0:?] OF REAL; END_ENTITY; END_SCHEMA;") + agg = ( + next(d for d in s.schema.declarations() if d.name() == "E") + .attributes()[0] + .type_of_attribute() + .as_aggregation_type() + ) + # import pdb; pdb.set_trace() + self.assertEqual((agg.bound1(), agg.bound2()), (0, -1)) + s.disown() + + def test_voxel_grid_with_dynamic_bound_loads(self): + """ + Array that is an expression : [1:dim_x*dim_y*dim_z] + Parsing must not crash, Bbund must be (1, -1) + """ + s = _parse(""" + SCHEMA t; + TYPE IfcBoolean = BOOLEAN; END_TYPE; + + ENTITY IfcVoxelHolder; + NumberOfVoxelsX : INTEGER; + NumberOfVoxelsY : INTEGER; + NumberOfVoxelsZ : INTEGER; + Voxels : ARRAY [1:NumberOfVoxelsX*NumberOfVoxelsY*NumberOfVoxelsZ] OF IfcBoolean; + END_ENTITY; + END_SCHEMA; + """) + holder = next(d for d in s.schema.declarations() if d.name() == "IfcVoxelHolder") + voxels = holder.attributes()[-1].type_of_attribute().as_aggregation_type() + self.assertEqual((voxels.bound1(), voxels.bound2()), (1, -1)) + s.disown() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/ifcopenshell-python/test/file_gc.py b/src/ifcopenshell-python/test/test_file_gc.py similarity index 99% rename from src/ifcopenshell-python/test/file_gc.py rename to src/ifcopenshell-python/test/test_file_gc.py index 483c268e17..532fa664ec 100644 --- a/src/ifcopenshell-python/test/file_gc.py +++ b/src/ifcopenshell-python/test/test_file_gc.py @@ -95,7 +95,7 @@ def test_bug_2486_a(): file = ifcopenshell.api.project.create_file() mymaterial = ifcopenshell.api.material.add_material(file) - pset = ifcopenshell.api.pset.add_pset(file, product=mymaterial) + pset = ifcopenshell.api.pset.add_pset(file, product=mymaterial, name="Foo") ifcopenshell.api.pset.edit_pset( file, pset=pset, diff --git a/src/ifcopenshell-python/test/global_id_updates.py b/src/ifcopenshell-python/test/test_global_id_updates.py similarity index 100% rename from src/ifcopenshell-python/test/global_id_updates.py rename to src/ifcopenshell-python/test/test_global_id_updates.py diff --git a/src/ifcopenshell-python/test/instance_string_formatting.py b/src/ifcopenshell-python/test/test_instance_string_formatting.py similarity index 100% rename from src/ifcopenshell-python/test/instance_string_formatting.py rename to src/ifcopenshell-python/test/test_instance_string_formatting.py diff --git a/src/ifcopenshell-python/test/test_package.py b/src/ifcopenshell-python/test/test_package.py index b251e081c7..c7ed42a26a 100644 --- a/src/ifcopenshell-python/test/test_package.py +++ b/src/ifcopenshell-python/test/test_package.py @@ -32,7 +32,7 @@ except: # - .github/workflows/ci-ifcopenshell-python.yml # - .github/workflows/ci-ifcopenshell-python-pypi.yml # - src/ifcopenshell-python/Makefile (PYVERSION check) -SUPPORTED_PY_VERSIONS = ("39", "310", "311", "312", "313", "314") +SUPPORTED_PY_VERSIONS = ("310", "311", "312", "313", "314") SUPPORTED_PLATFORMS = ("win64", "linux64", "macos64", "macosm164") WASM_SUPPORTED_PY_VERSIONS = ("313",) diff --git a/src/ifcopenshell-python/test/util/test_cost.py b/src/ifcopenshell-python/test/util/test_cost.py new file mode 100644 index 0000000000..516a69edd0 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_cost.py @@ -0,0 +1,52 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 Dion Moult +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import pytest + +import ifcopenshell.api.control +import ifcopenshell.api.cost +import test.bootstrap +import ifcopenshell.api.root + +import ifcopenshell.util.cost as subject + +class TestGetCostItemForProduct(test.bootstrap.IFC4): + def test_run(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1) + assert list(subject.get_cost_items_for_product(element)) == [item1] + + def test_remove_cost_item(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1) + ifcopenshell.api.cost.remove_cost_item(model, cost_item = item1) + assert list(subject.get_cost_items_for_product(element)) == [] + + def test_no_assigned_cost_items(self): + model = self.file + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model) + item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule) + assert list(subject.get_cost_items_for_product(element)) == [] + diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 1308e957c1..ffd5789f01 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -38,6 +38,7 @@ import ifcopenshell.api.sequence import ifcopenshell.api.spatial import ifcopenshell.api.style import ifcopenshell.api.type +import ifcopenshell.api.feature import ifcopenshell.guid import ifcopenshell.util.element as subject import test.bootstrap @@ -306,6 +307,35 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4): } } + def test_getting_complex_properties_verbose(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="pset") + complex_property = self.file.create_entity("IfcComplexProperty", Name="prop", UsageName="usage_name") + ifcopenshell.api.pset.edit_pset(self.file, pset=complex_property, properties={"a": "b"}) + pset.HasProperties = [complex_property] + properties = subject.get_properties(pset.HasProperties, verbose=True) + prop_value = properties["prop"]["value"] + nested_prop = prop_value["properties"]["a"] + assert properties == { + "prop": { + "id": complex_property.id(), + "class": "IfcComplexProperty", + "value": { + "UsageName": "usage_name", + "id": complex_property.id(), + "type": "IfcComplexProperty", + "properties": { + "a": { + "id": nested_prop["id"], + "class": "IfcPropertySingleValue", + "value": "b", + "value_type": "IfcLabel", + } + }, + }, + } + } + class TestGetElementsUsingPset(test.bootstrap.IFC4): def test_run(self): @@ -891,6 +921,35 @@ class TestGetlayers(test.bootstrap.IFC4, TestGetlayersIFC2X3): assert subject.get_layers(self.file, element) == [layer] +class TestGetParentIFC4(test.bootstrap.IFC4): + def test_getting_the_parent_of_an_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=building) + assert subject.get_parent(element) == building + + def test_getting_the_specific_parent_of_an_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + storey = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuildingStorey") + ifcopenshell.api.aggregate.assign_object(self.file, products=[storey], relating_object=building) + ifcopenshell.api.spatial.assign_container(self.file, products=[element], relating_structure=storey) + assert subject.get_parent(element, ifc_class="IfcBuilding") == building + assert subject.get_parent(element, ifc_class="IfcSite") == None + + def test_getting_the_specific_parent_of_an_element_via_voiding(self): + wall = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + building = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcBuilding") + ifcopenshell.api.spatial.assign_container(self.file, products=[wall], relating_structure=building) + opening = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement") + ifcopenshell.api.feature.add_feature(self.file, feature=opening, element=wall) + window = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWindow") + ifcopenshell.api.feature.add_filling(self.file, opening=opening, element=window) + assert subject.get_parent(window, ifc_class="IfcWall") == wall + assert subject.get_parent(window, ifc_class="IfcBuilding") == building + assert subject.get_parent(window, ifc_class="IfcSite") == None + + class TestGetContainerIFC4(test.bootstrap.IFC4): def test_getting_the_spatial_container_of_an_element(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py index 5ff8adc03f..6a01a74201 100644 --- a/src/ifcopenshell-python/test/util/test_shape_builder.py +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -41,7 +41,7 @@ from ifcopenshell.util.shape_builder import ( class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): def test_np_rotation_matrix(self): - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] + from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # 2D. assert np.allclose(Matrix.Rotation(radians(45), 2), np_rotation_matrix(radians(45), 2)) @@ -62,7 +62,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(Matrix.Rotation(*rotation_vector_args), np_rotation_matrix(*rotation_vector_args)) def test_np_matrix_to_euler(self): - from mathutils import Euler # pyright: ignore[reportMissingImports] + from mathutils import Euler # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Test 3x3. rot = Euler((0.5, 0.5, 0.5)).to_matrix() @@ -77,7 +77,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(rot.to_euler(), np_matrix_to_euler(V(rot))) def test_np_angle(self): - from mathutils import Vector # pyright: ignore[reportMissingImports] + from mathutils import Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] v1, v2 = (1, 0, 0), (0, 1, 0) angle = np_angle(v1, v2) @@ -100,7 +100,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert is_x(angle, radians(90)) def test_np_normal(self): - import mathutils.geometry # pyright: ignore[reportMissingImports] + import mathutils.geometry # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] vectors = (0, 0, 0), (1, 0, 0), (0, 1, 0) n = mathutils.geometry.normal(vectors) @@ -113,7 +113,7 @@ class TestMathutilsCompatibleMethods(test.bootstrap.IFC4): assert np.allclose(n, (0, 0, -1)) def test_np_intersect_line_line(self): - import mathutils.geometry # pyright: ignore[reportMissingImports] + import mathutils.geometry # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] p1, p2 = [0, 0, 0], [1, 1, 1] q1, q2 = [0, 1, 0], [1, 0, 1] diff --git a/src/ifcopenshell-python/type-check-requirements.txt b/src/ifcopenshell-python/type-check-requirements.txt new file mode 100644 index 0000000000..c797c28034 --- /dev/null +++ b/src/ifcopenshell-python/type-check-requirements.txt @@ -0,0 +1,31 @@ +beautifulsoup4 +cjio >=0.8, <0.10 +deepdiff +docutils +flask +isodate +jinja2 +lark +meshio +mysql-connector-python +networkx +numpy +odfpy +openpyxl +pandas +psutil +pydantic +PyP6Xer +pystache +pytest +python-dateutil +requests +scikit-learn +shapely +tabulate +toposort +typing-extensions +typst +xlsxwriter +xmlschema +xsdata diff --git a/src/ifcparse/IfcHierarchyHelper.h b/src/ifcparse/IfcHierarchyHelper.h index 019cbf69c1..cfe37b3717 100644 --- a/src/ifcparse/IfcHierarchyHelper.h +++ b/src/ifcparse/IfcHierarchyHelper.h @@ -475,7 +475,7 @@ class IFC_PARSE_API IfcHierarchyHelper : public IfcParse::IfcFile { t->set_attribute_value(1, owner_hist); int relating_index = 4; int related_index = 5; - if (T::Class().name() == "IfcRelContainedInSpatialStructure" || std::is_base_of::value) { + if (T::Class().name() == "IfcRelContainedInSpatialStructure" || T::Class().name() == "IfcRelReferencedInSpatialStructure" || std::is_base_of::value) { // some classes have attributes reversed. std::swap(relating_index, related_index); } diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 3dedd47a8e..349a81532d 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -358,6 +358,7 @@ class IFC_PARSE_API entity : public declaration { const std::vector& subtypes() const { return subtypes_; } const std::vector& attributes() const { return attributes_; } + const std::vector& inverse_attributes() const { return inverse_attributes_; } const std::vector& derived() const { return derived_; } const std::vector all_attributes() const { diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index 8a80b5d264..fac4573a23 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -67,9 +67,9 @@ class Patcher: def patch(self) -> None: import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.element - from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] + from mathutils import Matrix, Vector # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] if len(bpy.data.objects) > 0: bpy.data.batch_remove(bpy.data.objects) diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py index 4045e86aad..8cb68c3dfc 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevit2025TINs.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcPatch. If not, see . +from __future__ import annotations import logging from typing import Optional, TYPE_CHECKING @@ -25,7 +26,7 @@ import ifcopenshell import ifcopenshell.util.shape_builder if TYPE_CHECKING: - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] class Patcher: @@ -113,9 +114,9 @@ class Patcher: self.should_create_edges = should_create_edges def patch(self) -> None: - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.util.schema import ifcopenshell.util.unit @@ -166,9 +167,9 @@ class Patcher: self.file = tool.Ifc.get() def create_edges(self, obj: bpy.types.Object) -> None: - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import ifcopenshell.api.geometry import ifcopenshell.api.root import ifcopenshell.util.representation @@ -234,7 +235,7 @@ class Patcher: # No sharp faces from math import degrees - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool import ifcopenshell.api.geometry import ifcopenshell.api.root @@ -281,13 +282,13 @@ class Patcher: # This is crazy but we need a sharp face per island from math import degrees, radians, sin - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool import ifcopenshell.api.geometry import ifcopenshell.api.root import ifcopenshell.util.representation import ifcopenshell.util.shape_builder - from mathutils import Matrix # pyright: ignore[reportMissingImports] + from mathutils import Matrix # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] # Get the active object (assumed to have a mesh) mesh = obj.data diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py index a1912d3b6e..cd801d9fe3 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py @@ -80,9 +80,9 @@ class Patcher: def patch(self) -> None: from math import degrees - import bmesh # pyright: ignore[reportMissingImports] + import bmesh # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] import bonsai.tool as tool - import bpy # pyright: ignore[reportMissingImports] + import bpy # pyright: ignore[reportMissingImports] # ty:ignore[unresolved-import] props = tool.Project.get_project_props() props.should_use_native_meshes = True diff --git a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py index 38b01135b3..8da51a4ac8 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py @@ -97,5 +97,5 @@ class Patcher: relating_type=relating_type, related_objects=related_objects, should_map_representations=False, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] ) diff --git a/src/ifcpatch/ifcpatch/recipes/PurgeData.py b/src/ifcpatch/ifcpatch/recipes/PurgeData.py index 3c34927a63..0ea968f48a 100644 --- a/src/ifcpatch/ifcpatch/recipes/PurgeData.py +++ b/src/ifcpatch/ifcpatch/recipes/PurgeData.py @@ -57,13 +57,7 @@ class Patcher: def patch(self): self.file.header.file_name.name = "Rabbit" - self.file.header.file_name.time_stamp = ( - datetime.datetime.utcnow() - .replace(tzinfo=datetime.timezone.utc) - .astimezone() - .replace(microsecond=0) - .isoformat() - ) + self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat() self.file.header.file_name.preprocessor_version = "Rabbit" self.file.header.file_name.originating_system = "Rabbit" diff --git a/src/ifcpatch/test/test_ifcpatch.py b/src/ifcpatch/test/test_ifcpatch.py index 4d7e98e55a..d0976965b6 100644 --- a/src/ifcpatch/test/test_ifcpatch.py +++ b/src/ifcpatch/test/test_ifcpatch.py @@ -52,10 +52,10 @@ class Test: assert output.by_type("IfcProject")[0].GlobalId == project.GlobalId assert output.by_type("IfcWall")[0].GlobalId == wall.GlobalId - output_path = Path(tempfile.mktemp()) + output_path = Path(tempfile.mkstemp()[1]) try: - assert not output_path.exists() + assert output_path.stat().st_size == 0 ifcpatch.write(patcher.get_output(), output_path) - assert output_path.exists() + assert output_path.stat().st_size != 0 finally: output_path.unlink() diff --git a/src/ifcquery/Makefile b/src/ifcquery/Makefile new file mode 100644 index 0000000000..f77132db5e --- /dev/null +++ b/src/ifcquery/Makefile @@ -0,0 +1,10 @@ +PACKAGE_NAME:=ifcquery +include ../common.mk + +.PHONY: test +test: + pytest tests + +.PHONY: qa +qa: + black . diff --git a/src/ifcquery/README.md b/src/ifcquery/README.md new file mode 100644 index 0000000000..5cb895dc44 --- /dev/null +++ b/src/ifcquery/README.md @@ -0,0 +1,500 @@ + +# ifcquery + +A CLI tool for querying and inspecting IFC building models. All output is +structured JSON (or human-readable text), making it easy to pipe into other +tools or scripts. + +## Installation + +```bash +pip install ifcquery +``` + +Requires `ifcopenshell`. The `clash` subcommand additionally requires the +IfcOpenShell C++ geometry bindings (`ifcopenshell.geom`). + +## Usage + +``` +ifcquery [options] [--format json|text|ids] +``` + +The `--format` flag controls output: + +- `json` (default) -- structured JSON, suitable for piping to `jq` or `ifcedit foreach` +- `text` -- indented human-readable output +- `ids` -- comma-separated step IDs extracted from list results, suitable for piping directly into `ifcedit run` parameters + +## Subcommands + +### summary + +Get a model overview: schema version, entity counts, and project info. + +```bash +ifcquery model.ifc summary +``` + +```json +{ + "schema": "IFC4", + "total_entities": 1847, + "project": { + "id": 1, + "name": "Office Building", + "description": null + }, + "types": { + "IfcWall": 42, + "IfcSlab": 12, + "IfcWindow": 36 + } +} +``` + +### tree + +Display the spatial hierarchy from IfcProject down through sites, buildings, +storeys, and their contained elements. + +```bash +ifcquery model.ifc tree +``` + +```json +{ + "id": 1, + "type": "IfcProject", + "name": "Office Building", + "children": [ + { + "id": 2, + "type": "IfcSite", + "name": "Default Site", + "children": [ + { + "id": 3, + "type": "IfcBuilding", + "name": "Main Building", + "children": [ + { + "id": 4, + "type": "IfcBuildingStorey", + "name": "Ground Floor", + "elements": [ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcSlab", "name": "Floor001"} + ] + } + ] + } + ] + } + ] +} +``` + +### info + +Get detailed information about a specific element by step ID. + +```bash +ifcquery model.ifc info 10 +ifcquery model.ifc info '#10' +``` + +Returns attributes, property sets, type relationship, material assignment, +spatial container, and placement matrix. + +```json +{ + "id": 10, + "type": "IfcWall", + "attributes": { + "Name": "Wall001", + "Description": null, + "ObjectType": "LOADBEARING" + }, + "property_sets": { + "Pset_WallCommon": { + "IsExternal": true, + "FireRating": "2HR" + } + }, + "element_type": {"id": 50, "type": "IfcWallType", "name": "Standard"}, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "placement": [ + [1.0, 0.0, 0.0, 5.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0] + ] +} +``` + +### select + +Filter elements using the ifcopenshell selector syntax. + +```bash +ifcquery model.ifc select 'IfcWall' +ifcquery model.ifc select 'IfcWall, IfcSlab' +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 11, "type": "IfcWall", "name": "Wall002"}, + {"id": 20, "type": "IfcSlab", "name": "Floor001"} +] +``` + +Results are sorted by ID. + +Use `--format ids` to get a comma-separated list of step IDs for direct use +in `ifcedit run` parameters: + +```bash +ifcedit run model.ifc type.assign_type \ + --related_objects "$(ifcquery model.ifc --format ids select 'IfcWall')" \ + --relating_type 456 +``` + +### relations + +Show all relationships for an element, organized by category: hierarchy, +children, type relationships, groups, systems, material, and connections. + +```bash +ifcquery model.ifc relations 10 +``` + +```json +{ + "id": 10, + "type": "IfcWall", + "name": "Wall001", + "hierarchy": { + "parent": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + "container": {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"} + }, + "children": { + "openings": [{"id": 30, "type": "IfcOpeningElement", "name": "Opening01"}] + }, + "type_relationship": { + "type_of": {"id": 50, "type": "IfcWallType", "name": "Standard"} + }, + "material": {"id": 60, "type": "IfcMaterial", "name": "Concrete"} +} +``` + +Empty categories are omitted from output. + +Use `--traverse up` to walk the spatial hierarchy from the element up to +IfcProject: + +```bash +ifcquery model.ifc relations 10 --traverse up +``` + +```json +[ + {"id": 10, "type": "IfcWall", "name": "Wall001"}, + {"id": 4, "type": "IfcBuildingStorey", "name": "Ground Floor"}, + {"id": 3, "type": "IfcBuilding", "name": "Main Building"}, + {"id": 2, "type": "IfcSite", "name": "Default Site"}, + {"id": 1, "type": "IfcProject", "name": "Office Building"} +] +``` + +### validate + +Check the model for schema and constraint violations. + +```bash +ifcquery model.ifc validate +ifcquery model.ifc validate --rules +``` + +Options: + +- `--rules` -- also run the slower EXPRESS rules check (default: off) + +```json +{ + "valid": true, + "issues": [] +} +``` + +On an invalid model: + +```json +{ + "valid": false, + "issues": [ + {"level": "ERROR", "message": "Entity #42 IfcWall.GlobalId is not a valid IfcGloballyUniqueId"} + ] +} +``` + +### schedule + +List all work schedules and their task trees from the model. + +```bash +ifcquery model.ifc schedule +ifcquery model.ifc schedule --depth 1 +``` + +Options: + +- `--depth N` -- expand at most N levels of subtasks (default: unlimited). At the + cutoff, `subtasks` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 42, + "name": "Construction Schedule", + "predefined_type": "BASELINE", + "tasks": [ + { + "id": 55, + "name": "Phase 1", + "start": "2024-01-01T09:00:00", + "finish": "2024-06-30T17:00:00", + "is_milestone": false, + "outputs": [{"id": 10, "type": "IfcWall", "name": "Wall A"}], + "subtasks": [ + {"id": 56, "name": "Foundations", "start": null, "finish": null, + "is_milestone": false, "outputs": [], "subtasks": []} + ] + } + ] + } +] +``` + +### cost + +List all cost schedules and their cost item trees from the model. + +```bash +ifcquery model.ifc cost +ifcquery model.ifc cost --depth 2 +``` + +Options: + +- `--depth N` -- expand at most N levels of subitems (default: unlimited). At the + cutoff, `subitems` is replaced with `{"truncated": true, "count": N}`. + +```json +[ + { + "id": 100, + "name": "Bill of Quantities", + "predefined_type": "COSTPLAN", + "items": [ + { + "id": 110, + "name": "Concrete Works", + "values": [{"formula": "1200.00 = material(1200.0)", "category": "material"}], + "subitems": [ + {"id": 111, "name": "Formwork", "values": [], "subitems": []} + ] + } + ] + } +] +``` + +### schema + +Show IFC class documentation for any entity type, using the schema version of +the loaded model. + +```bash +ifcquery model.ifc schema IfcWall +ifcquery model.ifc schema IfcBuildingStorey +``` + +```json +{ + "description": "The wall represents a vertical construction ...", + "predefined_types": {"STANDARD": "A standard wall, extruded vertically ..."}, + "spec_url": "https://standards.buildingsmart.org/...", + "attributes": { + "Name": "Optional name for use by the participating software systems", + "ObjectPlacement": "Placement of the product in space ..." + } +} +``` + +Returns `{"error": "Unknown entity: Foo"}` for unrecognised types. + +### contexts + +List all geometric representation contexts and subcontexts in the model. + +```bash +ifcquery model.ifc contexts +``` + +```json +[ + { + "id": 5, + "type": "IfcGeometricRepresentationContext", + "context_type": "Model", + "subcontexts": [ + {"id": 6, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Body", "target_view": "MODEL_VIEW"}, + {"id": 7, "type": "IfcGeometricRepresentationSubContext", "context_identifier": "Axis", "target_view": "GRAPH_VIEW"} + ] + } +] +``` + +### materials + +List all materials and material sets used in the model, with their assigned elements. + +```bash +ifcquery model.ifc materials +``` + +```json +[ + { + "id": 60, + "type": "IfcMaterial", + "name": "Concrete", + "elements": [{"id": 10, "type": "IfcWall", "name": "Wall001"}] + } +] +``` + +### plot + +Generate a 2D technical drawing (floor plan, elevation, or section) of the model and write it to a file. + +```bash +ifcquery model.ifc plot -o output.svg --out-format svg +ifcquery model.ifc plot -o output.png --view floorplan --scale 0.01 +``` + +Options: + +- `-o, --output ` -- output file path (default: `.svg` or `.png`) +- `--out-format {svg,png,base64}` -- output format (default: `png`) +- `--view {floorplan,elevation,section,auto}` -- drawing view (default: `floorplan`) +- `--scale ` -- model-to-paper scale ratio (default: 0.01 = 1:100) +- `--width-mm ` -- paper width in mm (default: 297) +- `--height-mm ` -- paper height in mm (default: 420) +- `--png-width ` -- raster output width in pixels (default: 1024) +- `--png-height ` -- raster output height in pixels (default: 1024) + +Requires the IfcOpenShell drawing module (`ifcopenshell.draw`). PNG output additionally requires `cairosvg`. + +### render + +Render a 3D view of the model geometry to a PNG file. + +```bash +ifcquery model.ifc render -o output.png +ifcquery model.ifc render -o output.png --view iso --selector IfcWall +``` + +Options: + +- `--view {iso,top,south,north,east,west}` -- camera angle (default: `iso`) +- `--selector ` -- ifcopenshell selector to restrict rendered elements + +Requires `pyvista` and the IfcOpenShell C++ geometry bindings. + +### clash + +Check a single element for geometric intersections and clearance violations +against other elements. + +```bash +ifcquery model.ifc clash 10 +ifcquery model.ifc clash 10 --clearance 0.5 +ifcquery model.ifc clash 10 --scope all --tolerance 0.001 +``` + +Options: + +- `--clearance ` -- minimum clearance distance to check +- `--tolerance ` -- intersection tolerance (default: 0.002) +- `--scope {storey,all}` -- check against same-storey elements or all elements (default: storey) + +```json +{ + "element": {"id": 10, "type": "IfcWall", "name": "Wall001"}, + "scope": "storey", + "pass": false, + "checks": { + "intersection": { + "pass": false, + "tolerance": 0.002, + "clashes": [ + { + "element": {"id": 11, "type": "IfcWall", "name": "Wall002"}, + "type": "intersection", + "distance": 0.0, + "p1": [2.5, 2.5, 1.5], + "p2": [2.5, 2.5, 1.5] + } + ] + }, + "clearance": { + "pass": true, + "clearance": 0.5, + "clashes": [] + } + } +} +``` + +Requires the IfcOpenShell C++ geometry bindings. + +## Scripting with ifcedit + +`ifcquery` and `ifcedit` are designed to compose. Use `--format ids` to pass +query results directly into `ifcedit run` parameters, or pipe JSON into +`ifcedit foreach` to apply an operation to every matching element. + +```bash +# Remove all walls from their spatial container +ifcedit run model.ifc spatial.unassign_container \ + --products "$(ifcquery model.ifc --format ids select 'IfcWall')" + +# Delete every window (model opened and saved once) +ifcquery model.ifc select 'IfcWindow' | ifcedit foreach model.ifc root.remove_product --product {id} + +# Bulk rename all doors +ifcquery model.ifc select 'IfcDoor' | ifcedit foreach model.ifc attribute.edit_attributes \ + --product {id} --attributes '{"Name": "Door"}' + +# Render an element highlighted against everything related to it +ifcquery model.ifc render relations.png \ + --element "$(ifcquery model.ifc --format ids relations 42)" + +# Render a clash — subject and clashing elements highlighted together +ifcquery model.ifc render clash.png \ + --element "$(ifcquery model.ifc --format ids clash 42)" +``` + +See the `ifcedit` documentation for the full `foreach` reference. + +## Error handling + +Errors are written to stderr. Exit code is 0 on success, 1 on error. + +## License + +LGPLv3+ -- see the IfcOpenShell project license. diff --git a/src/ifcquery/ifcquery/__init__.py b/src/ifcquery/ifcquery/__init__.py new file mode 100644 index 0000000000..be5327b20d --- /dev/null +++ b/src/ifcquery/ifcquery/__init__.py @@ -0,0 +1,20 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +__version__ = version = "0.0.0" diff --git a/src/ifcquery/ifcquery/__main__.py b/src/ifcquery/ifcquery/__main__.py new file mode 100644 index 0000000000..d4b6141fd9 --- /dev/null +++ b/src/ifcquery/ifcquery/__main__.py @@ -0,0 +1,397 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import argparse +import json +import os +import sys + +import ifcopenshell + +from ifcquery import clash as clash_mod +from ifcquery import contexts as contexts_mod +from ifcquery import cost as cost_mod +from ifcquery import ( + info, + plot, + relations, + schedule, + schema, + select, + summary, + tree, +) +from ifcquery import ( + materials as materials_mod, +) +from ifcquery import ( + render as render_mod, +) +from ifcquery import validate as validate_mod + + +def parse_element_id(raw: str) -> int: + """Parse an element ID from '#123' or '123' format.""" + raw = raw.strip().lstrip("#") + return int(raw) + + +def format_output(data, fmt: str) -> str: + if fmt == "json": + return json.dumps(data, indent=2, ensure_ascii=False) + elif fmt == "text": + return _format_text(data) + elif fmt == "ids": + return _format_ids(data) + return json.dumps(data, indent=2, ensure_ascii=False) + + +def _format_ids(data) -> str: + """Extract 'id' fields from a list of dicts and return as comma-separated string. + + For dicts with a top-level 'elements' key (e.g. clash, relations output), + extracts from that flat summary list rather than the nested structure. + """ + if isinstance(data, list): + ids = [str(item["id"]) for item in data if isinstance(item, dict) and "id" in item] + return ",".join(ids) + if isinstance(data, dict): + if "elements" in data and isinstance(data["elements"], list): + return _format_ids(data["elements"]) + if "id" in data: + return str(data["id"]) + return "" + + +def _format_text(data, indent: int = 0) -> str: + prefix = " " * indent + lines = [] + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, (dict, list)): + lines.append(f"{prefix}{key}:") + lines.append(_format_text(value, indent + 1)) + else: + lines.append(f"{prefix}{key}: {value}") + elif isinstance(data, list): + for item in data: + if isinstance(item, dict): + lines.append(_format_text(item, indent)) + lines.append("") + else: + lines.append(f"{prefix}- {item}") + else: + lines.append(f"{prefix}{data}") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + prog="ifcquery", + description="Query and inspect IFC building models", + ) + parser.add_argument("ifc_file", help="Path to the IFC file") + parser.add_argument( + "--format", + choices=["json", "text", "ids"], + default="json", + dest="output_format", + help="Output format: json (default), text (human-readable), ids (comma-separated step IDs)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("summary", help="Model overview: schema, element counts, project info") + + subparsers.add_parser("tree", help="Spatial hierarchy tree") + + info_parser = subparsers.add_parser("info", help="Deep inspection of a specific element") + info_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + + select_parser = subparsers.add_parser("select", help="Filter elements using selector syntax") + select_parser.add_argument("query", help="Selector query string") + + relations_parser = subparsers.add_parser("relations", help="Show relationships for an element") + relations_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + relations_parser.add_argument("--traverse", choices=["up"], help="Traverse hierarchy (up: walk to IfcProject)") + + clash_parser = subparsers.add_parser("clash", help="Check element placement for clashes") + clash_parser.add_argument("element_id", help="Element step ID (e.g. 123 or #123)") + clash_parser.add_argument("--clearance", type=float, help="Minimum clearance distance") + clash_parser.add_argument("--tolerance", type=float, default=0.002, help="Intersection tolerance (default: 0.002)") + clash_parser.add_argument( + "--scope", choices=["storey", "all"], default="storey", help="Scope of elements to check (default: storey)" + ) + + validate_parser = subparsers.add_parser("validate", help="Schema/constraint validation") + validate_parser.add_argument( + "--rules", action="store_true", help="Also check EXPRESS rules (slower, default: false)" + ) + + schedule_parser = subparsers.add_parser("schedule", help="List work plans and tasks from the model") + schedule_parser.add_argument( + "--depth", type=int, default=None, metavar="N", help="Limit subtask expansion to N levels (default: unlimited)" + ) + + cost_parser = subparsers.add_parser("cost", help="List cost schedules and cost items from the model") + cost_parser.add_argument( + "--depth", + type=int, + default=None, + metavar="N", + help="Limit cost item expansion to N levels (default: unlimited)", + ) + + subparsers.add_parser("contexts", help="List geometric representation contexts and subcontexts") + + subparsers.add_parser("materials", help="List materials and material sets") + + schema_parser = subparsers.add_parser("schema", help="IFC class documentation") + schema_parser.add_argument("entity_type", help="IFC entity type (e.g. IfcWall)") + + render_parser = subparsers.add_parser("render", help="Render model geometry to a PNG image") + render_parser.add_argument( + "-o", "--output", default="", metavar="FILE", help="Output PNG path (default: .png)" + ) + render_parser.add_argument( + "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict rendered elements" + ) + render_parser.add_argument( + "--element", + default="", + metavar="ID[,ID...]", + help="Comma-separated step IDs of elements to highlight (rest rendered in grey)", + ) + render_parser.add_argument( + "--view", + choices=render_mod.VIEWS, + default="iso", + help="Camera angle (default: iso)", + ) + + plot_parser = subparsers.add_parser( + "plot", help="Plot model drawing (SVG via ifcopenshell.draw; optional PNG via CairoSVG)" + ) + plot_parser.add_argument( + "-o", + "--output", + default="", + metavar="FILE", + help="Output file path. Default depends on --out-format: .svg/.png", + ) + plot_parser.add_argument( + "--out-format", + choices=["svg", "png", "base64"], + default="png", + help="Output format: svg (write SVG), png (write PNG), base64 (print base64 in JSON/text). Default: png", + ) + plot_parser.add_argument( + "--selector", default="", metavar="QUERY", help="ifcopenshell selector to restrict plotted elements" + ) + plot_parser.add_argument( + "--element", default="", metavar="ID[,ID...]", help="Comma-separated step IDs of elements to highlight" + ) + plot_parser.add_argument( + "--view", + choices=getattr(plot, "VIEWS", ("floorplan", "elevation", "section", "auto")), + default="floorplan", + help="Drawing view (default: floorplan)", + ) + plot_parser.add_argument( + "--width-mm", + type=float, + default=297.0, + metavar="MM", + help="Paper width in mm (default: 297)", + ) + plot_parser.add_argument( + "--height-mm", + type=float, + default=420.0, + metavar="MM", + help="Paper height in mm (default: 420)", + ) + plot_parser.add_argument( + "--scale", + type=float, + default=1.0 / 100.0, + metavar="S", + help="Model-to-paper scale (default: 0.01 = 1:100)", + ) + plot_parser.add_argument( + "--png-width", + type=int, + default=1024, + metavar="PX", + help="PNG width in pixels (default: 1024)", + ) + plot_parser.add_argument( + "--png-height", + type=int, + default=1024, + metavar="PX", + help="PNG height in pixels (default: 1024)", + ) + + args = parser.parse_args() + + try: + model = ifcopenshell.open(args.ifc_file) + except Exception as e: + print(f"Error: Could not open IFC file: {e}", file=sys.stderr) + sys.exit(1) + + if args.command == "summary": + result = summary.summary(model) + elif args.command == "tree": + result = tree.tree(model) + elif args.command == "info": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = info.info(model, element) + elif args.command == "select": + result = select.select(model, args.query) + elif args.command == "relations": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + result = relations.relations(model, element, traverse=args.traverse) + elif args.command == "clash": + try: + element_id = parse_element_id(args.element_id) + except ValueError: + print(f"Error: Invalid element ID: {args.element_id}", file=sys.stderr) + sys.exit(1) + try: + element = model.by_id(element_id) + except RuntimeError: + print(f"Error: Element #{element_id} not found", file=sys.stderr) + sys.exit(1) + try: + result = clash_mod.clash( + model, element, clearance=args.clearance, tolerance=args.tolerance, scope=args.scope + ) + except ImportError: + print("Error: ifcopenshell geometry engine not available (C++ bindings required)", file=sys.stderr) + sys.exit(1) + elif args.command == "validate": + result = validate_mod.validate(model, express_rules=args.rules) + elif args.command == "schedule": + result = schedule.schedule(model, max_depth=args.depth) + elif args.command == "cost": + result = cost_mod.cost(model, max_depth=args.depth) + elif args.command == "contexts": + result = contexts_mod.contexts(model) + elif args.command == "materials": + result = materials_mod.materials(model) + elif args.command == "schema": + result = schema.schema(model, args.entity_type) + elif args.command == "render": + element_ids = None + if args.element: + try: + element_ids = [parse_element_id(part) for part in args.element.split(",")] + except ValueError: + print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) + sys.exit(1) + out_path = args.output or (os.path.splitext(args.ifc_file)[0] + ".png") + try: + png_bytes = render_mod.render( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + ) + except ImportError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + with open(out_path, "wb") as f: + f.write(png_bytes) + print(f"Saved render to {out_path}", file=sys.stderr) + return + elif args.command == "plot": + element_ids = None + if args.element: + try: + element_ids = [parse_element_id(part) for part in args.element.split(",")] + except ValueError: + print(f"Error: Invalid element ID(s): {args.element}", file=sys.stderr) + sys.exit(1) + + try: + result = plot.plot( + model, + selector=args.selector or None, + element_ids=element_ids, + view=args.view, + width_mm=args.width_mm, + height_mm=args.height_mm, + scale=args.scale, + output_format=args.out_format, + ) + except ImportError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + if args.out_format == "base64": + # result is a dict; serialise to stdout so callers can consume it + print(format_output(result, args.output_format)) + return + + # svg or png: write to a file + base = os.path.splitext(args.ifc_file)[0] + if args.out_format == "svg": + out_path = args.output or (base + ".svg") + else: + out_path = args.output or (base + ".png") + + with open(out_path, "wb") as f: + f.write(result) + + print(f"Saved drawing to {out_path}", file=sys.stderr) + return + + print(format_output(result, args.output_format)) + + +if __name__ == "__main__": + main() diff --git a/src/ifcquery/ifcquery/clash.py b/src/ifcquery/ifcquery/clash.py new file mode 100644 index 0000000000..52c8d53e3b --- /dev/null +++ b/src/ifcquery/ifcquery/clash.py @@ -0,0 +1,180 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import multiprocessing +import sys +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.element + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _get_scope_elements( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, scope: str +) -> tuple[set[ifcopenshell.entity_instance], str]: + """Return set of elements to check against and the effective scope used. + + Returns (elements, effective_scope) where effective_scope may differ from + the requested scope if fallback was needed. + """ + if scope == "storey": + container = ifcopenshell.util.element.get_container(element) + if container is not None: + siblings = set(ifcopenshell.util.element.get_contained(container)) + siblings.discard(element) + return siblings, "storey" + else: + print( + f"Warning: Element #{element.id()} has no spatial container, falling back to --scope all", + file=sys.stderr, + ) + + # scope == "all" or fallback + elements = set(model.by_type("IfcElement")) + elements -= set(model.by_type("IfcFeatureElement")) + elements.discard(element) + return elements, "all" + + +def _build_tree(model: ifcopenshell.file, elements: set[ifcopenshell.entity_instance]) -> ifcopenshell.geom.tree | None: + """Build geometry tree for given elements using iterator. + + Returns None if iterator fails to initialize (no geometry available). + """ + geom_settings = ifcopenshell.geom.settings() + geom_settings.set("use-world-coords", True) + geom_tree = ifcopenshell.geom.tree() + iterator = ifcopenshell.geom.iterator(geom_settings, model, multiprocessing.cpu_count(), include=list(elements)) + if not iterator.initialize(): + return None + while True: + geom_tree.add_element(iterator.get()) + if not iterator.next(): + break + return geom_tree + + +def _format_clash(clash_result, geom_tree: ifcopenshell.geom.tree, model: ifcopenshell.file) -> dict[str, Any]: + """Format a single clash result to dict.""" + # clash result .a/.b are C++ wrapper entity_instances without .Name; + # look up the Python entity from the model by id for proper serialization + other = model.by_id(clash_result.b.id()) + return { + "element": _ref(other), + "type": geom_tree.get_clash_type(clash_result.clash_type), + "distance": clash_result.distance, + "p1": list(clash_result.p1), + "p2": list(clash_result.p2), + } + + +def clash( + model: ifcopenshell.file, + element: ifcopenshell.entity_instance, + clearance: float | None = None, + tolerance: float = 0.002, + scope: str = "storey", +) -> dict[str, Any]: + """Check element for geometric clashes against other elements. + + :param model: The IFC model. + :param element: The element to check. + :param clearance: Minimum clearance distance; if provided, runs clearance check. + :param tolerance: Intersection tolerance in meters (default 0.002). + :param scope: Which elements to check against: "storey" or "all". + :return: Dict with clash results suitable for JSON serialization. + """ + result: dict[str, Any] = {"element": _ref(element)} + + # Get scope elements + scope_elements, effective_scope = _get_scope_elements(model, element, scope) + result["scope"] = effective_scope + + if not scope_elements: + result["pass"] = True + result["checks"] = {"intersection": {"pass": True, "tolerance": tolerance, "clashes": []}} + if clearance is not None: + result["checks"]["clearance"] = {"pass": True, "clearance": clearance, "clashes": []} + return result + + # Build geometry tree for target element + scope elements + all_elements = scope_elements | {element} + geom_tree = _build_tree(model, all_elements) + + if geom_tree is None: + result["pass"] = None + result["error"] = f"No geometry for element #{element.id()}" + return result + + # Run intersection check + intersection_clashes = geom_tree.clash_intersection_many( + [element], list(scope_elements), tolerance=tolerance, check_all=True + ) + intersection_results = [_format_clash(c, geom_tree, model) for c in intersection_clashes] + checks: dict[str, Any] = { + "intersection": { + "pass": len(intersection_results) == 0, + "tolerance": tolerance, + "clashes": intersection_results, + } + } + + all_pass = len(intersection_results) == 0 + + # Run clearance check if requested + if clearance is not None: + clearance_clashes = geom_tree.clash_clearance_many( + [element], list(scope_elements), clearance=clearance, check_all=True + ) + clearance_results = [_format_clash(c, geom_tree, model) for c in clearance_clashes] + checks["clearance"] = { + "pass": len(clearance_results) == 0, + "clearance": clearance, + "clashes": clearance_results, + } + if clearance_results: + all_pass = False + + result["pass"] = all_pass + result["checks"] = checks + + # Flat list of subject + all clashing elements across all checks, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = {element.id()} + involved = [_ref(element)] + for check in checks.values(): + for clash_item in check.get("clashes", []): + eid = clash_item["element"]["id"] + if eid not in seen: + seen.add(eid) + involved.append(clash_item["element"]) + result["elements"] = involved + + return result diff --git a/src/ifcquery/ifcquery/contexts.py b/src/ifcquery/ifcquery/contexts.py new file mode 100644 index 0000000000..912df173d7 --- /dev/null +++ b/src/ifcquery/ifcquery/contexts.py @@ -0,0 +1,44 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def contexts(model: ifcopenshell.file) -> list[dict]: + """Return all geometric representation contexts and subcontexts. + + :param model: The in-memory IFC model. + :return: List of dicts with id, type, context_type, context_identifier, + and (for subcontexts) target_view and parent_context_id. + """ + results = [] + for ctx in model.by_type("IfcGeometricRepresentationContext"): + entry = { + "id": ctx.id(), + "type": ctx.is_a(), + "context_type": getattr(ctx, "ContextType", None), + "context_identifier": getattr(ctx, "ContextIdentifier", None), + } + if ctx.is_a("IfcGeometricRepresentationSubContext"): + entry["target_view"] = ctx.TargetView + parent = ctx.ParentContext + entry["parent_context_id"] = parent.id() if parent else None + results.append(entry) + return results diff --git a/src/ifcquery/ifcquery/cost.py b/src/ifcquery/ifcquery/cost.py new file mode 100644 index 0000000000..ef9559d597 --- /dev/null +++ b/src/ifcquery/ifcquery/cost.py @@ -0,0 +1,45 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.cost as cost_util + + +def _cost_item_to_dict(item: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + raw_values = cost_util.get_cost_values(item) + values = [{"formula": v.get("label", ""), "category": v.get("category")} for v in raw_values] + + if max_depth is not None and depth >= max_depth: + child_count = len(cost_util.get_nested_cost_items(item)) + subitems = {"truncated": True, "count": child_count} if child_count else [] + else: + subitems = [_cost_item_to_dict(sub, max_depth, depth + 1) for sub in cost_util.get_nested_cost_items(item)] + + return { + "id": item.id(), + "name": getattr(item, "Name", None), + "values": values, + "subitems": subitems, + } + + +def cost(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcCostSchedule entries with nested cost item trees. + + max_depth limits how many levels of subitems are expanded (None = unlimited). + At the cutoff level, subitems is replaced with {"truncated": True, "count": N}. + """ + result = [] + for cost_schedule in model.by_type("IfcCostSchedule"): + items = [_cost_item_to_dict(i, max_depth, depth=1) for i in cost_util.get_root_cost_items(cost_schedule)] + result.append( + { + "id": cost_schedule.id(), + "name": getattr(cost_schedule, "Name", None), + "predefined_type": getattr(cost_schedule, "PredefinedType", None), + "items": items, + } + ) + return result diff --git a/src/ifcquery/ifcquery/info.py b/src/ifcquery/ifcquery/info.py new file mode 100644 index 0000000000..ad9a9daa4f --- /dev/null +++ b/src/ifcquery/ifcquery/info.py @@ -0,0 +1,262 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.placement + +# --------------------------------------------------------------------------- +# Geometry summary helpers +# --------------------------------------------------------------------------- + +_MAX_PROFILE_POINTS = 20 + + +def _rc(coords) -> list[float]: + """Round a coordinate sequence to 6 decimal places.""" + return [round(float(c), 6) for c in coords] + + +def _curve_points(curve) -> list | None: + if curve.is_a("IfcPolyline"): + return [_rc(p.Coordinates) for p in curve.Points] + if curve.is_a("IfcIndexedPolyCurve"): + return [_rc(c) for c in curve.Points.CoordList] + return None + + +def _profile_summary(profile) -> dict: + t = profile.is_a() + result: dict[str, Any] = {"type": t} + if t == "IfcRectangleProfileDef": + result["x_dim"] = profile.XDim + result["y_dim"] = profile.YDim + elif t in ("IfcCircleProfileDef", "IfcCircleHollowProfileDef"): + result["radius"] = profile.Radius + if t == "IfcCircleHollowProfileDef": + result["wall_thickness"] = profile.WallThickness + elif t in ("IfcArbitraryClosedProfileDef", "IfcArbitraryProfileDefWithVoids"): + pts = _curve_points(profile.OuterCurve) + if pts is not None: + if len(pts) <= _MAX_PROFILE_POINTS: + result["points"] = pts + else: + result["point_count"] = len(pts) + elif t == "IfcCompositeProfileDef": + result["profiles"] = [_profile_summary(p) for p in profile.Profiles] + return result + + +def _half_space_plane(half_space) -> dict | None: + if not half_space.is_a("IfcHalfSpaceSolid"): + return None + surface = half_space.BaseSurface + if not surface or not surface.is_a("IfcPlane"): + return None + pos = surface.Position + loc = _rc(pos.Location.Coordinates) + normal = _rc(pos.Axis.DirectionRatios) if pos.Axis else [0.0, 0.0, 1.0] + return {"location": loc, "normal": normal} + + +def _walk_clipping(item) -> tuple: + """Return (base_solid, [clipping_plane_dicts]) from a BooleanClippingResult chain.""" + planes = [] + current = item + while current.is_a("IfcBooleanClippingResult"): + plane = _half_space_plane(current.SecondOperand) + if plane: + planes.append(plane) + current = current.FirstOperand + return current, planes + + +def _swept_solid_dict(item) -> dict: + result: dict[str, Any] = {"solid_type": item.is_a()} + if item.is_a("IfcExtrudedAreaSolid"): + result["depth"] = item.Depth + if item.ExtrudedDirection: + result["direction"] = _rc(item.ExtrudedDirection.DirectionRatios) + if item.SweptArea: + result["profile"] = _profile_summary(item.SweptArea) + return result + + +def _summarize_rep(rep) -> dict: + rep_type = rep.RepresentationType or "" + result: dict[str, Any] = {"representation_type": rep_type} + items = list(rep.Items) + + if rep_type == "MappedRepresentation": + for item in items: + if item.is_a("IfcMappedItem"): + return _summarize_rep(item.MappingSource.MappedRepresentation) + + elif rep_type == "SweptSolid": + result["solids"] = [_swept_solid_dict(item) for item in items] + + elif rep_type == "Clipping": + solids = [] + for item in items: + base, planes = _walk_clipping(item) + solid = _swept_solid_dict(base) + if planes: + solid["clipping_planes"] = planes + solids.append(solid) + result["solids"] = solids + + elif rep_type == "CSG": + ops = [] + for item in items: + if hasattr(item, "Operator"): + ops.append({"operator": str(item.Operator), "type": item.is_a()}) + if ops: + result["operations"] = ops + + elif rep_type in ("Brep", "Tessellation", "SolidModel"): + face_count = 0 + vertex_count = 0 + for item in items: + if item.is_a("IfcPolygonalFaceSet"): + face_count += len(item.Faces) + vertex_count += len(item.Coordinates.CoordList) + elif item.is_a("IfcFacetedBrep"): + face_count += len(item.Outer.CfsFaces) + if face_count: + result["face_count"] = face_count + if vertex_count: + result["vertex_count"] = vertex_count + + return result + + +def _geometry_summary(element) -> dict | None: + if not hasattr(element, "Representation") or not element.Representation: + return None + body_rep = next( + (r for r in element.Representation.Representations if r.RepresentationIdentifier == "Body"), + None, + ) + if body_rep is None: + return None + try: + return _summarize_rep(body_rep) + except Exception: + return None + + +def _serialize_attribute(value: Any) -> Any: + """Convert an IFC attribute value to a JSON-serializable form.""" + if isinstance(value, ifcopenshell.entity_instance): + return {"id": value.id(), "type": value.is_a()} + if isinstance(value, tuple): + return [_serialize_attribute(v) for v in value] + return value + + +def _material_to_dict(material: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + """Convert a material entity to a summary dict.""" + if material is None: + return None + result: dict[str, Any] = { + "id": material.id(), + "type": material.is_a(), + } + if hasattr(material, "Name"): + result["name"] = material.Name + return result + + +def info(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return deep inspection data for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + + # Direct attributes via get_info() which returns a dict of all attributes + element_info = element.get_info() + attrs = {} + for key, value in element_info.items(): + if key in ("id", "type"): + continue + attrs[key] = _serialize_attribute(value) + result["attributes"] = attrs + + # Property sets and quantity sets + try: + psets = ifcopenshell.util.element.get_psets(element) + if psets: + result["property_sets"] = psets + except Exception: + pass + + # Element type + try: + element_type = ifcopenshell.util.element.get_type(element) + if element_type: + type_info: dict[str, Any] = { + "id": element_type.id(), + "type": element_type.is_a(), + } + if hasattr(element_type, "Name"): + type_info["name"] = element_type.Name + result["element_type"] = type_info + except Exception: + pass + + # Material + try: + material = ifcopenshell.util.element.get_material(element) + mat_dict = _material_to_dict(material) + if mat_dict: + result["material"] = mat_dict + except Exception: + pass + + # Spatial container + try: + container = ifcopenshell.util.element.get_container(element) + if container: + result["container"] = { + "id": container.id(), + "type": container.is_a(), + "name": container.Name if hasattr(container, "Name") else None, + } + except Exception: + pass + + # Placement (as 4x4 matrix) + try: + if hasattr(element, "ObjectPlacement") and element.ObjectPlacement: + matrix = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + result["placement"] = matrix.tolist() + except Exception: + pass + + # Geometry summary + geom = _geometry_summary(element) + if geom: + result["geometry_summary"] = geom + + return result diff --git a/src/ifcquery/ifcquery/materials.py b/src/ifcquery/ifcquery/materials.py new file mode 100644 index 0000000000..c7402d51e5 --- /dev/null +++ b/src/ifcquery/ifcquery/materials.py @@ -0,0 +1,100 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import ifcopenshell + + +def materials(model: ifcopenshell.file) -> list[dict]: + """Return all materials and material sets from the model. + + :param model: The in-memory IFC model. + :return: List of dicts covering IfcMaterial, IfcMaterialLayerSet, + IfcMaterialConstituentSet, and IfcMaterialProfileSet entities. + """ + results = [] + + for m in model.by_type("IfcMaterial"): + results.append( + { + "id": m.id(), + "type": "IfcMaterial", + "name": m.Name, + "category": getattr(m, "Category", None), + } + ) + + for ls in model.by_type("IfcMaterialLayerSet"): + layers = [] + for layer in ls.MaterialLayers or []: + layers.append( + { + "name": layer.Name, + "thickness": layer.LayerThickness, + "material": layer.Material.Name if layer.Material else None, + "is_ventilated": layer.IsVentilated, + } + ) + results.append( + { + "id": ls.id(), + "type": "IfcMaterialLayerSet", + "name": ls.LayerSetName, + "layers": layers, + } + ) + + for cs in model.by_type("IfcMaterialConstituentSet"): + constituents = [] + for c in cs.MaterialConstituents or []: + constituents.append( + { + "name": c.Name, + "material": c.Material.Name if c.Material else None, + "fraction": c.Fraction, + } + ) + results.append( + { + "id": cs.id(), + "type": "IfcMaterialConstituentSet", + "name": cs.Name, + "constituents": constituents, + } + ) + + for ps in model.by_type("IfcMaterialProfileSet"): + profiles = [] + for p in ps.MaterialProfiles or []: + profiles.append( + { + "name": p.Name, + "material": p.Material.Name if p.Material else None, + } + ) + results.append( + { + "id": ps.id(), + "type": "IfcMaterialProfileSet", + "name": ps.Name, + "profiles": profiles, + } + ) + + return results diff --git a/src/ifcquery/ifcquery/plot.py b/src/ifcquery/ifcquery/plot.py new file mode 100644 index 0000000000..3393a87fc8 --- /dev/null +++ b/src/ifcquery/ifcquery/plot.py @@ -0,0 +1,284 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import base64 +import os +from io import BytesIO +from typing import Any + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.util.selector + +try: + import ifcopenshell.draw + + _HAS_DRAW = True +except ImportError: + _HAS_DRAW = False + +from xml.etree.ElementTree import Element, ElementTree, SubElement, register_namespace + +try: + import cairosvg # type: ignore + + _HAS_CAIROSVG = True +except Exception: + _HAS_CAIROSVG = False + + +try: + from PIL import Image # type: ignore + + _HAS_PIL = True +except Exception: + _HAS_PIL = False + +VIEWS = ("floorplan", "elevation", "section", "auto") +OUTPUT_FORMATS = ("svg", "png", "base64") + + +def _escape_css_attr(name: str) -> str: + # CSS attribute selectors must escape ':' (e.g. ifc:guid -> ifc\:guid) + return name.replace(":", "\\:") + + +def _highlight_css_from_ids(model: ifcopenshell.file, element_ids: list[int]) -> str: + guids: list[str] = [] + for sid in element_ids: + try: + e = model.by_id(int(sid)) + except RuntimeError: + continue + if e is None: + continue + gid = getattr(e, "GlobalId", None) + if isinstance(gid, str) and gid: + guids.append(gid) + + if not guids: + return "" + + attr = _escape_css_attr("ifc:guid") + + css = [ + "/* Auto-highlight injected by ifcquery.plot */", + f"[{attr}] path {{ opacity: 0.10; }}", + f"[{attr}] text {{ opacity: 0.25; }}", + ] + for gid in guids: + css.append(f'[{attr}="{gid}"] path {{ opacity: 1.0; stroke: #d00; stroke-width: 0.25; }}') + css.append(f'[{attr}="{gid}"] text {{ opacity: 1.0; fill: #d00; }}') + return "\n".join(css) + "\n" + + +def _make_filtered_iterator(model: ifcopenshell.file, include_elements: list[Any]) -> ifcopenshell.geom.iterator: + # Avoid multiprocessing in WASM; os.cpu_count is good enough. + n_threads = os.cpu_count() or 1 + + # These flags mirror the defaults used by ifcopenshell.draw in v0.8.x. + geom_settings = ifcopenshell.geom.settings( + REORIENT_SHELLS=False, + ELEMENT_HIERARCHY=True, + ) + + # IfcOpenShell wrapper constants may live in different places across builds. + wrapper = getattr(ifcopenshell, "ifcopenshell_wrapper", None) + if wrapper is not None: + try: + geom_settings.set("iterator-output", wrapper.NATIVE) + except Exception: + pass + try: + geom_settings.set("apply-default-materials", True) + except Exception: + pass + try: + geom_settings.set("dimensionality", wrapper.SURFACES_AND_SOLIDS) + except Exception: + pass + + return ifcopenshell.geom.iterator(geom_settings, model, n_threads, include=include_elements) + + +def _diagnose_empty_drawing(model: ifcopenshell.file, view: str) -> str: + """Return a helpful error message when ifcopenshell.draw produces no geometry groups.""" + hints = [] + + if view in ("floorplan", "auto"): + storeys = model.by_type("IfcBuildingStorey") + if not storeys: + hints.append("the model has no IfcBuildingStorey entities (required for auto_floorplan)") + else: + null_elevation = [s for s in storeys if getattr(s, "Elevation", None) is None] + if null_elevation: + names = ", ".join(f'"{s.Name or s.GlobalId}"' for s in null_elevation) + hints.append( + f"storey Elevation is None for: {names} — " + "set IfcBuildingStorey.Elevation (e.g. 0.0) so the section cut height can be determined" + ) + + has_geom = any(getattr(e, "Representation", None) is not None for e in model.by_type("IfcProduct")) + if not has_geom: + hints.append("no IfcProduct entities have geometric representations") + + base = f"No plan geometry found for view={view!r}." + if hints: + return base + " Possible causes: " + "; ".join(hints) + "." + return base + " The model may lack geometry visible in this view." + + +def plot( + model: ifcopenshell.file, + *, + output_format: str = "png", + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "floorplan", + # SVG / page sizing (draw works in mm coordinates) + width_mm: float = 297.0, + height_mm: float = 420.0, + scale: float = 1.0 / 100.0, + merge_projection: bool = True, + # PNG sizing (only for output_format png/base64) + png_width: int = 1024, + png_height: int = 1024, +) -> bytes | dict[str, Any]: + """ + Plot IFC model as SVG (via ifcopenshell.draw) or PNG/base64 (via CairoSVG). + + Args: + model: In-memory IFC model. + output_format: 'svg' | 'png' | 'base64' + - 'svg' -> returns SVG bytes + - 'png' -> returns PNG bytes + - 'base64'-> returns dict: {mime, png_b64, width, height, view} + selector: ifcopenshell selector query to restrict plotted elements. + element_ids: STEP ids to highlight; non-highlighted geometry is faded. + view: One of VIEWS ('floorplan', 'elevation', 'section', 'auto'). + width_mm, height_mm: Page size in mm. + scale: Model-to-paper scale (0.01 means 1:100). + merge_projection: Passed through to ifcopenshell.draw.main. + png_width, png_height: Raster size in pixels for png/base64 outputs. + + Raises: + ImportError: if ifcopenshell.draw or CairoSVG is not available (as required). + ValueError: invalid args or selector matches nothing. + """ + if output_format not in OUTPUT_FORMATS: + raise ValueError(f"output_format must be one of {OUTPUT_FORMATS}, got {output_format!r}") + if view not in VIEWS: + raise ValueError(f"view must be one of {VIEWS}, got {view!r}") + if not _HAS_DRAW: + raise ImportError("ifcopenshell.draw is not available in this environment.") + + # Configure draw settings + settings = ifcopenshell.draw.draw_settings( + auto_floorplan=(view in ("floorplan", "auto")), + auto_elevation=(view in ("elevation", "auto")), + auto_section=(view in ("section", "auto")), + width=width_mm, + height=height_mm, + scale=scale, + css="", + ) + + # Optional highlight CSS overlay + if element_ids: + settings.css = _highlight_css_from_ids(model, element_ids) + + # Optional element restriction via selector -> custom iterator + iterators: tuple[Any, ...] = () + if selector: + include_elements = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not include_elements: + raise ValueError(f"Selector {selector!r} matched no elements") + it = _make_filtered_iterator(model, include_elements) + iterators = (it,) + # If we explicitly include elements, don't rely on exclude_entities (best-effort). + settings.exclude_entities = "" + + # Generate SVG + svg_bytes = ifcopenshell.draw.main( + settings, + files=[model], + iterators=iterators, + merge_projection=merge_projection, + ) + + register_namespace("", "http://www.w3.org/2000/svg") + + def svg_split(f): + x = ElementTree(file=f) + svg = x.getroot() + resources = [] + for child in svg: + if child.tag == "{http://www.w3.org/2000/svg}g": + root = Element(svg.tag, svg.attrib) + n = ElementTree(root) + for r in resources + [child]: + root.append(r) + b = BytesIO() + n.write(b, xml_declaration=True, encoding="utf-8", method="xml") + yield b.getvalue() + else: + resources.append(child) + + if output_format == "svg": + return svg_bytes + + # Need CairoSVG for png/base64 + if not _HAS_CAIROSVG: + raise ImportError("CairoSVG is not installed. Install with: pip install cairosvg") + + svgs = list(svg_split(BytesIO(svg_bytes))) + if not svgs: + raise ValueError(_diagnose_empty_drawing(model, view)) + + composite = None + png_bytes = None + for i, svgb in enumerate(svgs): + png_bytes = cairosvg.svg2png(bytestring=svgb, output_width=png_width, output_height=png_height) + if len(svgs) == 1: + break + + # Need Pillow for concatenating images + if not _HAS_PIL: + raise ImportError("Pillow is not installed. Install with: pip install Pillow") + + if composite is None: + composite = Image.new("RGBA", (png_width, png_height * len(svgs))) + img = Image.open(BytesIO(png_bytes)) + composite.paste(img, (0, png_height * i)) + if composite is not None: + b = BytesIO() + composite.save(b, "png") + png_bytes = b.getvalue() + + if output_format == "base64": + return { + "mime": "image/png", + "png_b64": base64.b64encode(png_bytes).decode(), + "width": png_width, + "height": png_height, + "view": view, + } + + return png_bytes diff --git a/src/ifcquery/ifcquery/relations.py b/src/ifcquery/ifcquery/relations.py new file mode 100644 index 0000000000..556ee0324d --- /dev/null +++ b/src/ifcquery/ifcquery/relations.py @@ -0,0 +1,197 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element +import ifcopenshell.util.system + + +def _ref(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Serialize an element to a compact reference dict.""" + result: dict[str, Any] = {"id": element.id(), "type": element.is_a()} + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + return result + + +def _ref_or_none(element: ifcopenshell.entity_instance | None) -> dict[str, Any] | None: + return _ref(element) if element is not None else None + + +def _ref_list(elements) -> list[dict[str, Any]]: + return [_ref(e) for e in elements] + + +def _traverse_up(element: ifcopenshell.entity_instance) -> list[dict[str, Any]]: + """Walk the hierarchy from element up to IfcProject.""" + chain = [_ref(element)] + current = element + while True: + parent = ifcopenshell.util.element.get_parent(current) + if parent is None: + break + chain.append(_ref(parent)) + current = parent + return chain + + +def _all_relations(model: ifcopenshell.file, element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Collect all relationships for an element.""" + result: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + } + if hasattr(element, "Name") and element.Name: + result["name"] = element.Name + + # Hierarchy (upward) + hierarchy: dict[str, Any] = {} + parent = ifcopenshell.util.element.get_parent(element) + if parent is not None: + hierarchy["parent"] = _ref(parent) + container = ifcopenshell.util.element.get_container(element) + if container is not None: + hierarchy["container"] = _ref(container) + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate is not None: + hierarchy["aggregate"] = _ref(aggregate) + nest = ifcopenshell.util.element.get_nest(element) + if nest is not None: + hierarchy["nest"] = _ref(nest) + filled_void = ifcopenshell.util.element.get_filled_void(element) + if filled_void is not None: + hierarchy["filled_void"] = _ref(filled_void) + voided_element = ifcopenshell.util.element.get_voided_element(element) + if voided_element is not None: + hierarchy["voided_element"] = _ref(voided_element) + if hierarchy: + result["hierarchy"] = hierarchy + + # Children (downward) + children: dict[str, Any] = {} + contained = ifcopenshell.util.element.get_contained(element) + if contained: + children["contained"] = _ref_list(contained) + parts = ifcopenshell.util.element.get_parts(element) + if parts: + children["parts"] = _ref_list(parts) + components = ifcopenshell.util.element.get_components(element) + if components: + children["components"] = _ref_list(components) + openings = list(ifcopenshell.util.element.get_openings(element)) + if openings: + children["openings"] = _ref_list(openings) + if children: + result["children"] = children + + # Type relationship + type_relationship: dict[str, Any] = {} + element_type = ifcopenshell.util.element.get_type(element) + if element_type is not None: + type_relationship["type_of"] = _ref(element_type) + try: + occurrences = ifcopenshell.util.element.get_types(element) + if occurrences: + type_relationship["occurrences"] = _ref_list(occurrences) + except Exception: + pass + if type_relationship: + result["type_relationship"] = type_relationship + + # Groups + groups = ifcopenshell.util.element.get_groups(element) + if groups: + result["groups"] = _ref_list(groups) + + # Systems + systems = ifcopenshell.util.system.get_element_systems(element) + if systems: + result["systems"] = _ref_list(systems) + + # Zones + zones = ifcopenshell.util.system.get_element_zones(element) + if zones: + result["zones"] = _ref_list(zones) + + # Material + material = ifcopenshell.util.element.get_material(element) + if material is not None: + result["material"] = _ref(material) + + # Referenced structures + referenced = ifcopenshell.util.element.get_referenced_structures(element) + if referenced: + result["referenced_structures"] = _ref_list(referenced) + + # Connections + connections: dict[str, Any] = {} + connected_to = ifcopenshell.util.system.get_connected_to(element) + if connected_to: + connections["connected_to"] = _ref_list(connected_to) + connected_from = ifcopenshell.util.system.get_connected_from(element) + if connected_from: + connections["connected_from"] = _ref_list(connected_from) + ports = ifcopenshell.util.system.get_ports(element) + if ports: + connections["ports"] = _ref_list(ports) + if connections: + result["connections"] = connections + + return result + + +def _collect_elements(data: Any, seen: set[int], result: list[dict[str, Any]]) -> None: + """Recursively collect all element refs (dicts with 'id') from a nested structure.""" + if isinstance(data, dict): + if "id" in data and isinstance(data["id"], int): + eid = data["id"] + if eid not in seen: + seen.add(eid) + result.append( + {"id": data["id"], "type": data.get("type"), "name": data.get("name")} + if "name" in data + else {"id": data["id"], "type": data.get("type")} + ) + for v in data.values(): + _collect_elements(v, seen, result) + elif isinstance(data, list): + for item in data: + _collect_elements(item, seen, result) + + +def relations( + model: ifcopenshell.file, element: ifcopenshell.entity_instance, traverse: str | None = None +) -> dict[str, Any] | list[dict[str, Any]]: + """Return relationships for an element, or hierarchy chain if traverse='up'.""" + if traverse == "up": + return _traverse_up(element) + result = _all_relations(model, element) + + # Flat list of subject + all referenced elements, deduplicated. + # Allows --format ids to extract all involved IDs without jq. + seen: set[int] = set() + elements: list[dict[str, Any]] = [] + _collect_elements(result, seen, elements) + result["elements"] = elements + + return result diff --git a/src/ifcquery/ifcquery/render.py b/src/ifcquery/ifcquery/render.py new file mode 100644 index 0000000000..44105e0ee2 --- /dev/null +++ b/src/ifcquery/ifcquery/render.py @@ -0,0 +1,465 @@ +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +import multiprocessing +import os +import tempfile + +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.guid +import ifcopenshell.util.selector + +try: + import numpy as np + import pyvista as pv + + _HAS_PYVISTA = True +except ImportError: + _HAS_PYVISTA = False + +VIEWS = ("iso", "top", "south", "north", "east", "west") + + +def _apply_view(plotter: pv.Plotter, view: str) -> None: + """Set the camera to the requested named view. Z is up (IFC convention).""" + if view == "top": + plotter.view_xy() + elif view == "south": + # Camera at -Y looking toward +Y (south face of building) + plotter.view_xz(negative=True) + elif view == "north": + plotter.view_xz(negative=False) + elif view == "east": + plotter.view_yz(negative=False) + elif view == "west": + plotter.view_yz(negative=True) + else: + plotter.view_isometric() + # Ensure Z is world up for elevation views + if view not in ("top",): + plotter.camera.up = (0, 0, 1) + + +def _add_shape( + shape: object, + plotter: pv.Plotter, + highlight_ids: frozenset[int] | None, +) -> None: + """Triangulate and add a geometry shape to the plotter.""" + geom = shape.geometry + verts = np.array(geom.verts, dtype=float).reshape(-1, 3) + if verts.size == 0: + return + + raw_faces = np.array(geom.faces, dtype=int) + if raw_faces.size == 0 or raw_faces.size % 3 != 0: + return # degenerate geometry from kernel — skip silently + faces = raw_faces.reshape(-1, 3) + material_ids = np.array(geom.material_ids, dtype=int) + + is_subject = highlight_ids is not None and shape.product.id() in highlight_ids + + for midx, mat in enumerate(geom.materials): + tri_mask = material_ids == midx + if not np.any(tri_mask): + continue + + sub_faces = faces[tri_mask] + faces_pv = np.hstack([np.full((sub_faces.shape[0], 1), 3, dtype=int), sub_faces]).ravel() + mesh = pv.PolyData(verts, faces_pv) + + if highlight_ids is not None and not is_subject: + color = (180, 180, 180) + opacity = 0.10 + else: + diffuse = np.clip(np.array(mat.diffuse.components), 0.0, 1.0) + color = tuple((diffuse * 255).astype(np.uint8)) + transparency = mat.transparency if mat.transparency == mat.transparency else 0.0 + opacity = float(np.clip(1.0 - transparency, 0.0, 1.0)) + + plotter.add_mesh(mesh, color=color, opacity=opacity, show_edges=False) + + +def _render_iterator( + iterator: object, + highlight_ids: list[int] | None, + view: str, +) -> bytes: + """Drive a geometry iterator into a pyvista plotter and return PNG bytes.""" + plotter = pv.Plotter(off_screen=True, window_size=(1280, 960)) + plotter.background_color = "white" + + while True: + try: + _add_shape(iterator.get(), plotter, highlight_ids=frozenset(highlight_ids) if highlight_ids else None) + except Exception: + pass # skip broken shapes, keep rendering the rest + if not iterator.next(): + break + + plotter.reset_camera() + _apply_view(plotter, view) + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".png") + os.close(tmp_fd) + try: + plotter.show(screenshot=tmp_path, auto_close=True) + with open(tmp_path, "rb") as f: + return f.read() + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def _build_geom_settings(model: ifcopenshell.file) -> ifcopenshell.geom.settings: + """Build geometry settings, excluding Clearance subcontexts.""" + settings = ifcopenshell.geom.settings() + settings.set("use-world-coords", True) + + clearance_ids = { + c.id() for c in model.by_type("IfcGeometricRepresentationSubContext") if c.ContextIdentifier == "Clearance" + } + if clearance_ids: + ctx_ids = [c.id() for c in model.by_type("IfcGeometricRepresentationContext") if c.id() not in clearance_ids] + if ctx_ids: + settings.set("context-ids", ctx_ids) + + return settings + + +def _get_occurrence_class(type_entity) -> str: + """Derive the occurrence IFC class from a type entity class name.""" + type_class = type_entity.is_a() + if type_class.endswith("Type"): + return type_class[:-4] + return "IfcBuildingElementProxy" + + +def _make_type_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for *type_entity* using its RepresentationMaps. + + The occurrence is added to *model* and references the type's existing + RepresentationMap entities via IfcMappedItem. Returns the occurrence entity, + or ``None`` when the type has no usable RepresentationMaps. + + .. note:: + This function is intended for use on a temporary model copy. The + caller is responsible for discarding that copy after rendering. + """ + rep_maps = getattr(type_entity, "RepresentationMaps", None) or [] + if not rep_maps: + return None + + # One IfcMappedItem per RepresentationMap. + mapped_items = [] + for rep_map in rep_maps: + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + transform = model.create_entity( + "IfcCartesianTransformationOperator3D", + LocalOrigin=origin, + ) + mapped_item = model.create_entity( + "IfcMappedItem", + MappingSource=rep_map, + MappingTarget=transform, + ) + mapped_items.append(mapped_item) + + context = rep_maps[0].MappedRepresentation.ContextOfItems + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=context, + RepresentationIdentifier="Body", + RepresentationType="MappedRepresentation", + Items=mapped_items, + ) + prod_def_shape = model.create_entity( + "IfcProductDefinitionShape", + Representations=[shape_rep], + ) + + # Identity placement. + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + occurrence = model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_type_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + return occurrence + + +def _make_profile_occurrence(model: ifcopenshell.file, type_entity) -> object | None: + """Create a temporary occurrence for a type that has a material profile set. + + Finds the first profile in the type's IfcMaterialProfileSet and creates a + 1-metre IfcExtrudedAreaSolid body representation from it. Returns the + occurrence, or ``None`` when no usable profile is found. + + .. note:: + Intended for use on a temporary model copy; caller discards it after + rendering. + """ + # Locate the first profile from the type's material profile set. + profile = None + for rel in getattr(type_entity, "HasAssociations", []): + if not rel.is_a("IfcRelAssociatesMaterial"): + continue + mat = rel.RelatingMaterial + if mat.is_a("IfcMaterialProfileSetUsage"): + mat = mat.ForProfileSet + if mat.is_a("IfcMaterialProfileSet"): + mat_profiles = list(getattr(mat, "MaterialProfiles", None) or []) + if mat_profiles: + profile = getattr(mat_profiles[0], "Profile", None) + if profile is not None: + break + if profile is None: + return None + + # Find a Body subcontext, or fall back to any Model context. + body_ctx = None + for ctx in model.by_type("IfcGeometricRepresentationSubContext"): + if ctx.ContextIdentifier == "Body": + body_ctx = ctx + break + if body_ctx is None: + for ctx in model.by_type("IfcGeometricRepresentationContext"): + if ctx.ContextType == "Model": + body_ctx = ctx + break + if body_ctx is None: + return None + + # Extrude 1 metre along Z (profile lies in XY plane). + origin = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_axis = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_axis = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + position = model.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_axis, RefDirection=x_axis) + extrude_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + extrusion = model.create_entity( + "IfcExtrudedAreaSolid", + SweptArea=profile, + Position=position, + ExtrudedDirection=extrude_dir, + Depth=1.0, + ) + shape_rep = model.create_entity( + "IfcShapeRepresentation", + ContextOfItems=body_ctx, + RepresentationIdentifier="Body", + RepresentationType="SweptSolid", + Items=[extrusion], + ) + prod_def_shape = model.create_entity( + "IfcProductDefinitionShape", + Representations=[shape_rep], + ) + + # Identity placement. + pt = model.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = model.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = model.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + axis2 = model.create_entity("IfcAxis2Placement3D", Location=pt, Axis=z_dir, RefDirection=x_dir) + placement = model.create_entity("IfcLocalPlacement", RelativePlacement=axis2) + + occ_class = _get_occurrence_class(type_entity) + try: + occurrence = model.create_entity( + occ_class, + GlobalId=ifcopenshell.guid.new(), + Name=f"_profile_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + except Exception: + occurrence = model.create_entity( + "IfcBuildingElementProxy", + GlobalId=ifcopenshell.guid.new(), + Name=f"_profile_preview_{type_entity.id()}", + ObjectPlacement=placement, + Representation=prod_def_shape, + ) + return occurrence + + +def _render_with_types( + model: ifcopenshell.file, + types: list, + selector_elements: list | None, + element_ids: list[int] | None, + type_highlight_ids: set[int], + view: str, +) -> bytes: + """Render type entities by creating occurrences in a temporary model copy. + + *types* — list of IfcTypeProduct entities to render. + *selector_elements* — non-type elements from the selector (or ``None``). + *element_ids* — original highlight IDs (may contain type IDs). + *type_highlight_ids* — subset of *element_ids* that are type IDs. + """ + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".ifc") + os.close(tmp_fd) + try: + model.write(tmp_path) + tmp = ifcopenshell.open(tmp_path) + + # Map original type step-ID → new occurrence step-ID in the tmp model. + type_id_to_occ_id: dict[int, int] = {} + for t in types: + tmp_type = tmp.by_id(t.id()) + occ = _make_type_occurrence(tmp, tmp_type) or _make_profile_occurrence(tmp, tmp_type) + if occ: + type_id_to_occ_id[t.id()] = occ.id() + + if not type_id_to_occ_id: + raise ValueError("Type entities have no RepresentationMaps or material profile sets to render") + + include = [tmp.by_id(occ_id) for occ_id in type_id_to_occ_id.values()] + if selector_elements: + include.extend(tmp.by_id(e.id()) for e in selector_elements) + + settings = _build_geom_settings(tmp) + iterator = ifcopenshell.geom.iterator(settings, tmp, multiprocessing.cpu_count(), include=include) + if not iterator.initialize(): + raise ValueError("Type entities have no renderable geometry") + + # Remap type IDs → occurrence IDs in the highlight list. + new_highlight = None + if element_ids: + new_highlight = [] + for hid in element_ids: + if hid in type_highlight_ids: + mapped = type_id_to_occ_id.get(hid) + if mapped: + new_highlight.append(mapped) + else: + new_highlight.append(hid) + + return _render_iterator(iterator, new_highlight, view) + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def render( + model: ifcopenshell.file, + selector: str | None = None, + element_ids: list[int] | None = None, + view: str = "iso", +) -> bytes: + """Render IFC model geometry to a PNG image. + + Supports both element instances and element types (e.g. ``IfcWallType``). + When type entities are targeted — via *selector* or *element_ids* — a + temporary copy of the model is used to create proxy occurrences that + reference the type's RepresentationMaps; the original model is not + modified. + + :param model: The in-memory IFC model. + :param selector: ifcopenshell selector to restrict rendered elements + (e.g. ``'IfcWall'``, ``'IfcWallType'``, or + ``'IfcBuildingStorey[Name="Ground Floor"]'``). + When omitted the whole model is rendered. + :param element_ids: Step IDs of elements (or types) to highlight. The + rest of the model is rendered in translucent grey so the highlighted + items stand out. + :param view: Camera angle: ``iso``, ``top``, ``south``, ``north``, + ``east``, or ``west``. Defaults to ``iso``. + :return: PNG image as raw bytes. + :raises ImportError: If pyvista is not installed. + :raises ValueError: If the selector matches nothing or the model has no + renderable geometry. + """ + if not _HAS_PYVISTA: + raise ImportError("pyvista is not installed. Install with: pip install pyvista") + + # --- Partition selector results into types and elements --- + if selector: + matched = list(ifcopenshell.util.selector.filter_elements(model, selector)) + if not matched: + raise ValueError(f"Selector {selector!r} matched no elements") + types = [e for e in matched if e.is_a("IfcTypeProduct")] + selector_elements: list | None = [e for e in matched if not e.is_a("IfcTypeProduct")] + else: + types = [] + selector_elements = None # no restriction — render all elements + + # --- Collect any type entities from element_ids --- + type_highlight_ids: set[int] = set() + if element_ids: + for eid in element_ids: + entity = model.by_id(eid) + if entity.is_a("IfcTypeProduct"): + type_highlight_ids.add(eid) + seen = {t.id() for t in types} + if eid not in seen: + types.append(entity) + + # --- Delegate to temp-copy path when any type entities are involved --- + if types: + return _render_with_types(model, types, selector_elements, element_ids, type_highlight_ids, view) + + # --- Regular element rendering --- + settings = _build_geom_settings(model) + + if selector_elements is not None: + if not selector_elements: + raise ValueError(f"Selector {selector!r} matched only type entities (use a type selector or IfcElement)") + iterator = ifcopenshell.geom.iterator( + settings, + model, + multiprocessing.cpu_count(), + include=selector_elements, + ) + else: + exclude = list(model.by_type("IfcOpeningElement")) + iterator = ifcopenshell.geom.iterator( + settings, + model, + multiprocessing.cpu_count(), + exclude=exclude if exclude else None, + ) + + if not iterator.initialize(): + raise ValueError("No renderable geometry found in model (or selector matched nothing)") + + return _render_iterator(iterator, element_ids, view) diff --git a/src/ifcquery/ifcquery/schedule.py b/src/ifcquery/ifcquery/schedule.py new file mode 100644 index 0000000000..b3e3ae45d6 --- /dev/null +++ b/src/ifcquery/ifcquery/schedule.py @@ -0,0 +1,56 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.sequence as seq + + +def _task_to_dict(task: ifcopenshell.entity_instance, max_depth: int | None, depth: int) -> dict[str, Any]: + task_time = task.TaskTime + start = None + finish = None + if task_time: + start = task_time.ScheduleStart + finish = task_time.ScheduleFinish + + outputs = [] + for product in seq.get_task_outputs(task): + outputs.append({"id": product.id(), "type": product.is_a(), "name": getattr(product, "Name", None)}) + + if max_depth is not None and depth >= max_depth: + child_count = len(seq.get_nested_tasks(task)) + subtasks = {"truncated": True, "count": child_count} if child_count else [] + else: + subtasks = [_task_to_dict(sub, max_depth, depth + 1) for sub in seq.get_nested_tasks(task)] + + return { + "id": task.id(), + "name": getattr(task, "Name", None), + "start": start, + "finish": finish, + "is_milestone": bool(task.IsMilestone) if hasattr(task, "IsMilestone") else False, + "outputs": outputs, + "subtasks": subtasks, + } + + +def schedule(model: ifcopenshell.file, max_depth: int | None = None) -> list[dict[str, Any]]: + """Return a list of IfcWorkSchedule entries with nested task trees. + + max_depth limits how many levels of subtasks are expanded (None = unlimited). + At the cutoff level, subtasks is replaced with {"truncated": True, "count": N}. + """ + result = [] + for work_schedule in model.by_type("IfcWorkSchedule"): + tasks = [_task_to_dict(t, max_depth, depth=1) for t in seq.get_root_tasks(work_schedule)] + result.append( + { + "id": work_schedule.id(), + "name": getattr(work_schedule, "Name", None), + "predefined_type": getattr(work_schedule, "PredefinedType", None), + "tasks": tasks, + } + ) + return result diff --git a/src/ifcquery/ifcquery/schema.py b/src/ifcquery/ifcquery/schema.py new file mode 100644 index 0000000000..ad47486db2 --- /dev/null +++ b/src/ifcquery/ifcquery/schema.py @@ -0,0 +1,19 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.doc + + +def schema(model: ifcopenshell.file, entity_type: str) -> dict[str, Any]: + """Return IFC class documentation for entity_type from model's schema version.""" + schema_name = model.schema + try: + doc = ifcopenshell.util.doc.get_entity_doc(schema_name, entity_type) + except Exception: + return {"error": f"Unknown entity: {entity_type}"} + if not doc: + return {"error": f"Unknown entity: {entity_type}"} + return dict(doc) diff --git a/src/ifcquery/ifcquery/select.py b/src/ifcquery/ifcquery/select.py new file mode 100644 index 0000000000..f3ee1dceaa --- /dev/null +++ b/src/ifcquery/ifcquery/select.py @@ -0,0 +1,50 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.selector + + +def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]: + """Filter elements using ifcopenshell selector syntax and return matching element summaries. + + Examples: + - ``IfcWall`` — all walls + - ``IfcWall, IfcColumn`` — walls and columns + - ``! IfcWall`` — everything except walls + - ``IfcWall, Name = "My Wall"`` — walls with a specific name attribute + - ``type = "Concrete Wall"`` — elements assigned that type product + - ``material = "Concrete"`` — elements with that material + """ + elements = ifcopenshell.util.selector.filter_elements(model, query) + results = [] + for element in sorted(elements, key=lambda e: e.id()): + entry: dict[str, Any] = { + "id": element.id(), + "type": element.is_a(), + "repr": str(element), + } + if hasattr(element, "Name"): + entry["name"] = element.Name + results.append(entry) + return results diff --git a/src/ifcquery/ifcquery/summary.py b/src/ifcquery/ifcquery/summary.py new file mode 100644 index 0000000000..d13f070472 --- /dev/null +++ b/src/ifcquery/ifcquery/summary.py @@ -0,0 +1,52 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +from collections import Counter +from typing import Any + +import ifcopenshell + + +def summary(model: ifcopenshell.file) -> dict[str, Any]: + """Return a model overview with schema, element counts, and project info.""" + # Count elements by IFC type, sorted by count descending + type_counter: Counter[str] = Counter() + total = 0 + for entity in model: + type_counter[entity.is_a()] += 1 + total += 1 + + result: dict[str, Any] = { + "schema": model.schema, + "total_entities": total, + } + + projects = model.by_type("IfcProject") + if projects: + project = projects[0] + result["project"] = { + "id": project.id(), + "name": project.Name, + "description": project.Description, + } + + result["types"] = dict(type_counter.most_common()) + return result diff --git a/src/ifcquery/ifcquery/tree.py b/src/ifcquery/ifcquery/tree.py new file mode 100644 index 0000000000..1cbbaeb986 --- /dev/null +++ b/src/ifcquery/ifcquery/tree.py @@ -0,0 +1,68 @@ +# This file was generated with the assistance of an AI coding tool. +# IfcQuery - IFC model interrogation CLI +# Copyright (C) 2026 Bruno Postle +# +# This file is part of IfcQuery. +# +# IfcQuery is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcQuery is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcQuery. If not, see . + +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.util.element + + +def _element_summary(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Return a minimal summary dict for an element.""" + return { + "id": element.id(), + "type": element.is_a(), + "name": element.Name if hasattr(element, "Name") else None, + } + + +def _build_spatial_node(element: ifcopenshell.entity_instance) -> dict[str, Any]: + """Recursively build a spatial tree node.""" + node = _element_summary(element) + + # Get aggregated children (Site in Project, Building in Site, Storey in Building, etc.) + aggregates = [] + for rel in getattr(element, "IsDecomposedBy", []): + for child in rel.RelatedObjects: + aggregates.append(_build_spatial_node(child)) + + # Get contained elements (walls, slabs, etc. in a storey/space) + contained = [] + for rel in getattr(element, "ContainsElements", []): + for child in rel.RelatedElements: + contained.append(_element_summary(child)) + + if aggregates: + node["children"] = aggregates + if contained: + node["elements"] = contained + + return node + + +def tree(model: ifcopenshell.file) -> dict[str, Any] | list[dict[str, Any]]: + """Return the spatial hierarchy tree starting from IfcProject.""" + projects = model.by_type("IfcProject") + if not projects: + return {"error": "No IfcProject found in model"} + if len(projects) == 1: + return _build_spatial_node(projects[0]) + return [_build_spatial_node(p) for p in projects] diff --git a/src/ifcquery/ifcquery/validate.py b/src/ifcquery/ifcquery/validate.py new file mode 100644 index 0000000000..d35d9150af --- /dev/null +++ b/src/ifcquery/ifcquery/validate.py @@ -0,0 +1,15 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +from typing import Any + +import ifcopenshell +import ifcopenshell.validate + + +def validate(model: ifcopenshell.file, express_rules: bool = False) -> dict[str, Any]: + """Validate the model and return a dict with 'valid' bool and 'issues' list.""" + logger = ifcopenshell.validate.json_logger() + ifcopenshell.validate.validate(model, logger, express_rules=express_rules) + issues = [{"level": s["level"], "message": s["message"]} for s in logger.statements] + return {"valid": len(issues) == 0, "issues": issues} diff --git a/src/ifcquery/pyproject.toml b/src/ifcquery/pyproject.toml new file mode 100644 index 0000000000..3c8e734474 --- /dev/null +++ b/src/ifcquery/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ifcquery" +version = "0.0.0" +authors = [ + { name="Bruno Postle", email="bruno@postle.net" }, +] +description = "CLI tool for querying and inspecting IFC building models" +readme = "README.md" +keywords = ["IFC", "BIM", "Query"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", +] +dependencies = ["ifcopenshell"] + +[project.scripts] +ifcquery = "ifcquery.__main__:main" + +[project.urls] +Homepage = "http://ifcopenshell.org" +Documentation = "https://docs.ifcopenshell.org" +Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues" + +[tool.setuptools.packages.find] +include = ["ifcquery*"] +exclude = ["test*"] + +[tool.ruff] +extend = "../../pyproject.toml" diff --git a/src/ifcquery/tests/__init__.py b/src/ifcquery/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcquery/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcquery/tests/conftest.py b/src/ifcquery/tests/conftest.py new file mode 100644 index 0000000000..496e74cd6e --- /dev/null +++ b/src/ifcquery/tests/conftest.py @@ -0,0 +1,36 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + + +@pytest.fixture +def model(): + """Create an IFC4 model with a spatial hierarchy and a wall.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + slab = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSlab", name="Slab001") + ifcopenshell.api.spatial.assign_container(f, products=[slab], relating_structure=storey) + + return f diff --git a/src/ifcquery/tests/test_clash.py b/src/ifcquery/tests/test_clash.py new file mode 100644 index 0000000000..150200f95c --- /dev/null +++ b/src/ifcquery/tests/test_clash.py @@ -0,0 +1,330 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import numpy as np +import pytest + +from ifcquery.clash import clash + +try: + import ifcopenshell.geom + + HAS_GEOM = True +except ImportError: + HAS_GEOM = False + +pytestmark = pytest.mark.skipif(not HAS_GEOM, reason="ifcopenshell geometry engine not available") + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + # Create geometry context + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall 1 at origin + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + # Wall 2 perpendicular, crossing through wall 1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + # Wall 3 far away (10m offset in Y) + wall3 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall003") + rep3 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall3, representation=rep3) + ifcopenshell.api.spatial.assign_container(f, products=[wall3], relating_structure=storey) + matrix3 = np.eye(4) + matrix3[1, 3] = 10.0 # 10m in Y direction + ifcopenshell.api.geometry.edit_object_placement(f, product=wall3, matrix=matrix3) + + # Wall 4 close but not overlapping (0.3m offset in Y, wall thickness is 0.2m) + wall4 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall004") + rep4 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall4, representation=rep4) + ifcopenshell.api.spatial.assign_container(f, products=[wall4], relating_structure=storey) + matrix4 = np.eye(4) + matrix4[1, 3] = 0.3 # 0.3m in Y (gap of 0.1m from wall1) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall4, matrix=matrix4) + + return f + + +@pytest.fixture +def model_two_storeys(): + """Create a model with walls in different storeys.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + storey2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="First Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey1, storey2], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Wall in storey 1 + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="GroundWall") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey1) + + # Wall in storey 2, perpendicular and crossing wall1 + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="FirstFloorWall") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey2) + matrix2 = np.array([[0, -1, 0, 2.5], [1, 0, 0, -2.0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +class TestNoClashes: + def test_no_clashes_far_apart(self, model_with_geometry): + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") + result = clash(model_with_geometry, wall3) + assert result["pass"] is True + assert result["checks"]["intersection"]["pass"] is True + assert result["checks"]["intersection"]["clashes"] == [] + + def test_no_clashes_empty_scope(self, model_with_geometry): + """A model where the element is the only one in scope should pass.""" + # Create a model with a single wall + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="OnlyWall") + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + result = clash(f, wall) + assert result["pass"] is True + + +class TestIntersectionDetected: + def test_overlapping_walls(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + assert result["pass"] is False + assert result["checks"]["intersection"]["pass"] is False + clashes = result["checks"]["intersection"]["clashes"] + assert len(clashes) > 0 + # Wall002 should be in the clashes (it overlaps wall1) + clash_ids = {c["element"]["id"] for c in clashes} + wall2 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall002") + assert wall2.id() in clash_ids + + def test_clash_has_points(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + clashes = result["checks"]["intersection"]["clashes"] + for c in clashes: + assert "p1" in c + assert "p2" in c + assert len(c["p1"]) == 3 + assert len(c["p2"]) == 3 + assert "type" in c + assert "distance" in c + + +class TestClearance: + def test_clearance_violation(self, model_with_geometry): + """Wall004 is 0.1m from wall1; clearance of 0.5m should fail.""" + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1, clearance=0.5) + assert "clearance" in result["checks"] + # Wall004 should violate clearance + clearance_clashes = result["checks"]["clearance"]["clashes"] + clash_ids = {c["element"]["id"] for c in clearance_clashes} + wall4 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall004") + assert wall4.id() in clash_ids + assert result["checks"]["clearance"]["pass"] is False + + def test_clearance_pass(self, model_with_geometry): + """Wall003 is 10m away; clearance of 0.5m should pass for wall003.""" + wall3 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall003") + result = clash(model_with_geometry, wall3, clearance=0.5) + assert result["checks"]["clearance"]["pass"] is True + assert result["checks"]["clearance"]["clashes"] == [] + + def test_clearance_not_included_by_default(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + assert "clearance" not in result["checks"] + + +class TestScope: + def test_scope_storey_excludes_other_storeys(self, model_two_storeys): + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") + result = clash(model_two_storeys, wall1, scope="storey") + assert result["scope"] == "storey" + # No clashes because the overlapping wall is in a different storey + assert result["pass"] is True + + def test_scope_all_includes_other_storeys(self, model_two_storeys): + wall1 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "GroundWall") + result = clash(model_two_storeys, wall1, scope="all") + assert result["scope"] == "all" + # Should detect clash with the other-storey wall + assert result["pass"] is False + clash_ids = {c["element"]["id"] for c in result["checks"]["intersection"]["clashes"]} + wall2 = next(w for w in model_two_storeys.by_type("IfcWall") if w.Name == "FirstFloorWall") + assert wall2.id() in clash_ids + + +class TestNoGeometry: + def test_no_geometry_error(self, model): + """Element without geometry reports error.""" + wall = model.by_type("IfcWall")[0] + result = clash(model, wall) + assert result["pass"] is None + assert "error" in result + assert "No geometry" in result["error"] + + +class TestJsonSerializable: + def test_result_serializable(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1) + serialized = json.dumps(result) + parsed = json.loads(serialized) + assert parsed["element"]["type"] == "IfcWall" + + def test_clearance_result_serializable(self, model_with_geometry): + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = clash(model_with_geometry, wall1, clearance=0.5) + serialized = json.dumps(result) + parsed = json.loads(serialized) + assert "clearance" in parsed["checks"] + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_clash_json(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["element"]["type"] == "IfcWall" + assert "checks" in data + assert "intersection" in data["checks"] + finally: + os.unlink(path) + + def test_clash_with_clearance(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--clearance", "0.5"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert "clearance" in data["checks"] + finally: + os.unlink(path) + + def test_clash_scope_all(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + wall1 = next(w for w in model_with_geometry.by_type("IfcWall") if w.Name == "Wall001") + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", str(wall1.id()), "--scope", "all"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["scope"] == "all" + finally: + os.unlink(path) + + def test_clash_bad_id(self, model_with_geometry): + path = self._ifc_path(model_with_geometry) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "clash", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_contexts.py b/src/ifcquery/tests/test_contexts.py new file mode 100644 index 0000000000..c7a21acb96 --- /dev/null +++ b/src/ifcquery/tests/test_contexts.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.context +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit + +from ifcquery.contexts import contexts + + +class TestContexts: + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = contexts(f) + assert isinstance(result, list) + assert len(result) == 0 + + def test_model_context(self, model): + import ifcopenshell.api.context + + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + assert len(result) == 1 + entry = result[0] + assert entry["type"] == "IfcGeometricRepresentationContext" + assert entry["context_type"] == "Model" + assert "id" in entry + assert "context_identifier" in entry + + def test_subcontext(self, model): + import ifcopenshell.api.context + + model_ctx = ifcopenshell.api.context.add_context(model, context_type="Model") + ifcopenshell.api.context.add_context( + model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + result = contexts(model) + assert len(result) == 2 + subctx = next(e for e in result if e["type"] == "IfcGeometricRepresentationSubContext") + assert subctx["context_identifier"] == "Body" + assert subctx["target_view"] == "MODEL_VIEW" + assert subctx["parent_context_id"] == model_ctx.id() + + def test_ids_are_integers(self, model): + import ifcopenshell.api.context + + ifcopenshell.api.context.add_context(model, context_type="Model") + result = contexts(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_cost.py b/src/ifcquery/tests/test_cost.py new file mode 100644 index 0000000000..1011beca0a --- /dev/null +++ b/src/ifcquery/tests/test_cost.py @@ -0,0 +1,108 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.cost +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import pytest + +from ifcquery.cost import cost + + +@pytest.fixture +def cost_model(): + """Create an IFC4 model with a cost schedule, a top-level item, and one nested subitem.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + cs = ifcopenshell.api.cost.add_cost_schedule(f, name="Bill of Quantities") + item = ifcopenshell.api.cost.add_cost_item(f, cost_schedule=cs) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=item, attributes={"Name": "Concrete Works"}) + cv = ifcopenshell.api.cost.add_cost_value(f, parent=item) + ifcopenshell.api.cost.edit_cost_value(f, cost_value=cv, attributes={"AppliedValue": 1200.0, "Category": "material"}) + + # Add a nested subitem + subitem = ifcopenshell.api.cost.add_cost_item(f, cost_item=item) + ifcopenshell.api.cost.edit_cost_item(f, cost_item=subitem, attributes={"Name": "Formwork"}) + + return f + + +class TestCost: + def test_returns_list(self, cost_model): + result = cost(cost_model) + assert isinstance(result, list) + + def test_finds_cost_schedule(self, cost_model): + result = cost(cost_model) + assert len(result) == 1 + + def test_schedule_has_name(self, cost_model): + result = cost(cost_model) + assert result[0]["name"] == "Bill of Quantities" + + def test_schedule_has_id(self, cost_model): + result = cost(cost_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_schedule_has_items(self, cost_model): + result = cost(cost_model) + assert len(result[0]["items"]) == 1 + + def test_item_has_required_fields(self, cost_model): + result = cost(cost_model) + item = result[0]["items"][0] + assert "id" in item + assert "name" in item + assert "values" in item + assert "subitems" in item + + def test_item_name(self, cost_model): + result = cost(cost_model) + assert result[0]["items"][0]["name"] == "Concrete Works" + + def test_item_has_values(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert len(values) == 1 + assert "formula" in values[0] + assert "category" in values[0] + + def test_item_value_category(self, cost_model): + result = cost(cost_model) + values = result[0]["items"][0]["values"] + assert values[0]["category"] == "material" + + def test_empty_model_returns_empty_list(self, model): + result = cost(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, cost_model): + result = cost(cost_model, max_depth=None) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert len(item["subitems"]) == 1 + assert item["subitems"][0]["name"] == "Formwork" + + def test_max_depth_1_truncates_subitems(self, cost_model): + result = cost(cost_model, max_depth=1) + item = result[0]["items"][0] + assert isinstance(item["subitems"], dict) + assert item["subitems"]["truncated"] is True + assert item["subitems"]["count"] == 1 + + def test_max_depth_2_expands_to_depth_2(self, cost_model): + result = cost(cost_model, max_depth=2) + item = result[0]["items"][0] + assert isinstance(item["subitems"], list) + assert item["subitems"][0]["name"] == "Formwork" + # subitem has no children, so subitems should be empty list + assert item["subitems"][0]["subitems"] == [] diff --git a/src/ifcquery/tests/test_info.py b/src/ifcquery/tests/test_info.py new file mode 100644 index 0000000000..c17331a24d --- /dev/null +++ b/src/ifcquery/tests/test_info.py @@ -0,0 +1,112 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.unit +import ifcopenshell.util.representation +import ifcopenshell.util.shape_builder + +from ifcquery.info import info + + +class TestInfo: + def test_basic_attributes(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["attributes"]["Name"] == "Wall001" + + def test_container(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert result["container"]["type"] == "IfcBuildingStorey" + assert result["container"]["name"] == "Ground Floor" + + def test_project_info(self, model): + project = model.by_type("IfcProject")[0] + result = info(model, project) + assert result["type"] == "IfcProject" + assert result["attributes"]["Name"] == "TestProject" + + def test_all_attributes_serializable(self, model): + """All attribute values should be JSON-serializable (no entity instances).""" + import json + + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + # Should not raise + json.dumps(result) + + def test_no_geometry_summary_without_representation(self, model): + wall = model.by_type("IfcWall")[0] + result = info(model, wall) + assert "geometry_summary" not in result + + +class TestGeometrySummary: + def _make_model_with_wall(self): + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject") + ifcopenshell.api.unit.assign_unit(f) + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + ifcopenshell.api.context.add_context( + f, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=model_ctx, + ) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="W1") + ifcopenshell.api.geometry.edit_object_placement(f, product=wall) + return f, wall + + def _body_context(self, f): + return ifcopenshell.util.representation.get_context(f, "Model", "Body", "MODEL_VIEW") + + def test_swept_solid_summary(self): + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "SweptSolid" + assert len(gs["solids"]) == 1 + solid = gs["solids"][0] + assert solid["depth"] == 3000.0 # stored in project units (mm) + assert solid["profile"]["type"] == "IfcArbitraryClosedProfileDef" + assert len(solid["profile"]["points"]) == 5 # closed polyline + + def test_clipping_summary(self): + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation( + f, + context=body, + length=5.0, + height=4.0, + thickness=0.2, + clippings=[{"location": (0.0, 0.0, 3.0), "normal": (0.0, 0.0, 1.0)}], + ) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + gs = result["geometry_summary"] + assert gs["representation_type"] == "Clipping" + solid = gs["solids"][0] + assert len(solid["clipping_planes"]) == 1 + plane = solid["clipping_planes"][0] + assert plane["location"][2] == 3000.0 # stored in project units (mm) + assert plane["normal"] == [0.0, 0.0, 1.0] + + def test_geometry_summary_json_serializable(self): + import json + + f, wall = self._make_model_with_wall() + body = self._body_context(f) + rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5.0, height=3.0, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall, representation=rep) + result = info(f, wall) + json.dumps(result) diff --git a/src/ifcquery/tests/test_main.py b/src/ifcquery/tests/test_main.py new file mode 100644 index 0000000000..164b93152d --- /dev/null +++ b/src/ifcquery/tests/test_main.py @@ -0,0 +1,121 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.project +import pytest + + +@pytest.fixture +def ifc_path(model): + """Write the model fixture to a temp file and return its path.""" + with tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) as f: + model.write(f.name) + yield f.name + os.unlink(f.name) + + +def run_ifcquery(*args): + """Run ifcquery as a subprocess and return (returncode, stdout, stderr).""" + result = subprocess.run( + [sys.executable, "-m", "ifcquery", *args], + capture_output=True, + text=True, + ) + return result.returncode, result.stdout, result.stderr + + +class TestCLI: + def test_summary_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "summary") + assert rc == 0 + data = json.loads(stdout) + assert data["schema"] == "IFC4" + assert "types" in data + + def test_tree_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "tree") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcProject" + + def test_info_json(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", str(wall.id())) + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_info_hash_id(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "info", f"#{wall.id()}") + assert rc == 0 + data = json.loads(stdout) + assert data["type"] == "IfcWall" + + def test_select_json(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "select", "IfcWall") + assert rc == 0 + data = json.loads(stdout) + assert len(data) == 1 + assert data[0]["type"] == "IfcWall" + + def test_text_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "text", "summary") + assert rc == 0 + assert "schema:" in stdout + + def test_bad_file(self): + rc, stdout, stderr = run_ifcquery("/nonexistent.ifc", "summary") + assert rc != 0 + assert "Error" in stderr + + def test_bad_element_id(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "info", "999999") + assert rc != 0 + assert "Error" in stderr + + def test_no_command(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path) + assert rc != 0 + + def test_select_ids_format(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcWall") + assert rc == 0 + # Should be a comma-separated string of integers with no surrounding whitespace + ids = stdout.strip() + assert ids != "" + for part in ids.split(","): + assert part.isdigit() + + def test_select_ids_format_multiple(self, ifc_path, model): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcElement") + assert rc == 0 + ids = stdout.strip().split(",") + assert len(ids) >= 2 + + def test_ids_format_empty_result(self, ifc_path): + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "select", "IfcDoor") + assert rc == 0 + assert stdout.strip() == "" + + def test_relations_ids_format(self, ifc_path, model): + storey = model.by_type("IfcBuildingStorey")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "relations", str(storey.id())) + assert rc == 0 + ids = stdout.strip().split(",") + assert all(i.isdigit() for i in ids) + # should include the storey itself and its contained elements + assert str(storey.id()) in ids + wall_id = str(model.by_type("IfcWall")[0].id()) + assert wall_id in ids + + def test_info_ids_format(self, ifc_path, model): + wall = model.by_type("IfcWall")[0] + rc, stdout, stderr = run_ifcquery(ifc_path, "--format", "ids", "info", str(wall.id())) + assert rc == 0 + assert stdout.strip() == str(wall.id()) diff --git a/src/ifcquery/tests/test_materials.py b/src/ifcquery/tests/test_materials.py new file mode 100644 index 0000000000..aa6886156f --- /dev/null +++ b/src/ifcquery/tests/test_materials.py @@ -0,0 +1,52 @@ +import ifcopenshell.api.material +import ifcopenshell.api.project + +from ifcquery.materials import materials + + +class TestMaterials: + def test_empty_model(self, model): + result = materials(model) + assert isinstance(result, list) + assert len(result) == 0 + + def test_single_material(self, model): + ifcopenshell.api.material.add_material(model, name="Concrete", category="concrete") + result = materials(model) + assert len(result) == 1 + m = result[0] + assert m["type"] == "IfcMaterial" + assert m["name"] == "Concrete" + assert m["category"] == "concrete" + assert isinstance(m["id"], int) + + def test_material_layer_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Brick") + layer_set = ifcopenshell.api.material.add_material_set(model, name="BrickSet", set_type="IfcMaterialLayerSet") + ifcopenshell.api.material.add_layer(model, layer_set=layer_set, material=mat) + result = materials(model) + layer_sets = [e for e in result if e["type"] == "IfcMaterialLayerSet"] + assert len(layer_sets) == 1 + ls = layer_sets[0] + assert ls["name"] == "BrickSet" + assert isinstance(ls["layers"], list) + assert len(ls["layers"]) == 1 + layer = ls["layers"][0] + assert layer["material"] == "Brick" + + def test_material_constituent_set(self, model): + mat = ifcopenshell.api.material.add_material(model, name="Steel") + cs = ifcopenshell.api.material.add_material_set(model, name="CompSet", set_type="IfcMaterialConstituentSet") + ifcopenshell.api.material.add_constituent(model, constituent_set=cs, material=mat) + result = materials(model) + constituent_sets = [e for e in result if e["type"] == "IfcMaterialConstituentSet"] + assert len(constituent_sets) == 1 + entry = constituent_sets[0] + assert entry["name"] == "CompSet" + assert isinstance(entry["constituents"], list) + + def test_ids_are_integers(self, model): + ifcopenshell.api.material.add_material(model, name="Wood") + result = materials(model) + for entry in result: + assert isinstance(entry["id"], int) diff --git a/src/ifcquery/tests/test_plot.py b/src/ifcquery/tests/test_plot.py new file mode 100644 index 0000000000..4c003ab946 --- /dev/null +++ b/src/ifcquery/tests/test_plot.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import base64 +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import pytest + +from ifcquery.plot import _highlight_css_from_ids, plot + +try: + import ifcopenshell.draw # noqa: F401 + + HAS_DRAW = True +except ImportError: + HAS_DRAW = False + +try: + import cairosvg # noqa: F401 + + HAS_CAIROSVG = True +except ImportError: + HAS_CAIROSVG = False + +pytestmark = pytest.mark.skipif(not HAS_DRAW, reason="ifcopenshell.draw not available") + +SVG_MAGIC = b" elements, PNG/base64 should raise a clear error.""" + + def test_empty_drawing_png_raises(self, model_no_plan): + """PNG format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="png") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + def test_empty_drawing_base64_raises(self, model_no_plan): + """base64 format raises ValueError (not silently returns None) for empty drawings.""" + model, _ = model_no_plan + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.raises(ValueError, plot, model, output_format="base64") + else: + pytest.skip("Model produced non-empty SVG — empty path not triggered") + + +@pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") +class TestPlotPNG: + """PNG and base64 require cairosvg.""" + + def test_png_returns_bytes_or_raises_on_empty(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="png") + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="png") + + def test_base64_returns_dict(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if has_groups: + result = plot(model, output_format="base64") + assert isinstance(result, dict) + assert result["mime"] == "image/png" + assert "png_b64" in result + assert "width" in result + assert "height" in result + assert "view" in result + # Verify the base64 is valid PNG + decoded = base64.b64decode(result["png_b64"]) + assert decoded[:4] == PNG_MAGIC + else: + with pytest.raises(ValueError, match="No plan geometry"): + plot(model, output_format="base64") + + def test_base64_view_field_matches_requested(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="base64", view="floorplan") + assert result["view"] == "floorplan" + + def test_png_custom_size(self, model_with_annotations): + model, _ = model_with_annotations + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG") + result = plot(model, output_format="png", png_width=512, png_height=512) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_plot_svg_writes_file(self, model_with_annotations): + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + out_path = ifc_path.replace(".ifc", "_out.svg") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "svg", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(5) == SVG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + @pytest.mark.skipif(not HAS_CAIROSVG, reason="cairosvg not installed") + def test_plot_base64_prints_json(self, model_with_annotations): + """base64 format prints JSON to stdout instead of writing a file.""" + model, _ = model_with_annotations + ifc_path = self._ifc_path(model) + try: + # First check if the model would produce geometry + svg = plot(model, output_format="svg") + has_groups = b"" in svg + if not has_groups: + pytest.skip("Model produces empty SVG — base64 would raise ValueError") + + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "plot", "--out-format", "base64"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + # Output should be JSON (not an error) and contain base64 key + assert "png_b64" in result.stdout + finally: + try: + os.unlink(ifc_path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_relations.py b/src/ifcquery/tests/test_relations.py new file mode 100644 index 0000000000..32bc8bf3cb --- /dev/null +++ b/src/ifcquery/tests/test_relations.py @@ -0,0 +1,199 @@ +# This file was generated with the assistance of an AI coding tool. +import json +import os +import subprocess +import sys +import tempfile + +from ifcquery.relations import relations + + +class TestWallRelations: + def test_wall_has_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["id"] == wall.id() + assert result["type"] == "IfcWall" + assert result["hierarchy"]["container"]["type"] == "IfcBuildingStorey" + assert result["hierarchy"]["container"]["name"] == "Ground Floor" + + def test_wall_has_parent(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert result["hierarchy"]["parent"]["type"] == "IfcBuildingStorey" + + def test_wall_no_children(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "children" not in result + + def test_wall_empty_categories_omitted(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + assert "groups" not in result + assert "systems" not in result + assert "zones" not in result + assert "connections" not in result + assert "referenced_structures" not in result + + +class TestStoreyRelations: + def test_storey_has_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + contained_types = {e["type"] for e in result["children"]["contained"]} + assert "IfcWall" in contained_types + assert "IfcSlab" in contained_types + + def test_storey_has_aggregate_parent(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + assert result["hierarchy"]["aggregate"]["type"] == "IfcBuilding" + assert result["hierarchy"]["aggregate"]["name"] == "TestBuilding" + + +class TestProjectRelations: + def test_project_has_parts(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + parts = result["children"]["parts"] + assert any(p["type"] == "IfcSite" for p in parts) + + def test_project_no_hierarchy(self, model): + project = model.by_type("IfcProject")[0] + result = relations(model, project) + assert "hierarchy" not in result + + +class TestTraverseUp: + def test_wall_to_project(self, model): + wall = model.by_type("IfcWall")[0] + chain = relations(model, wall, traverse="up") + assert isinstance(chain, list) + assert chain[0]["type"] == "IfcWall" + assert chain[-1]["type"] == "IfcProject" + types = [e["type"] for e in chain] + assert "IfcBuildingStorey" in types + assert "IfcBuilding" in types + assert "IfcSite" in types + + def test_project_traverse(self, model): + project = model.by_type("IfcProject")[0] + chain = relations(model, project, traverse="up") + assert len(chain) == 1 + assert chain[0]["type"] == "IfcProject" + + def test_storey_to_project(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + chain = relations(model, storey, traverse="up") + assert chain[0]["type"] == "IfcBuildingStorey" + assert chain[-1]["type"] == "IfcProject" + assert len(chain) == 4 # storey -> building -> site -> project + + +class TestElementsSummary: + def test_wall_elements_includes_self(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + assert wall.id() in ids + + def test_wall_elements_includes_container(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + ids = [e["id"] for e in result["elements"]] + storey = model.by_type("IfcBuildingStorey")[0] + assert storey.id() in ids + + def test_storey_elements_includes_contained(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + wall = model.by_type("IfcWall")[0] + assert wall.id() in ids + + def test_elements_no_duplicates(self, model): + storey = model.by_type("IfcBuildingStorey")[0] + result = relations(model, storey) + ids = [e["id"] for e in result["elements"]] + assert len(ids) == len(set(ids)) + + def test_elements_all_have_id_and_type(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + for e in result["elements"]: + assert "id" in e + assert "type" in e + + def test_traverse_up_has_no_elements_field(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + assert isinstance(result, list) + assert not any("elements" in item for item in result) + + +class TestJsonSerializable: + def test_relations_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall) + json.dumps(result) + + def test_traverse_serializable(self, model): + wall = model.by_type("IfcWall")[0] + result = relations(model, wall, traverse="up") + json.dumps(result) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_relations_json(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id())], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert data["type"] == "IfcWall" + assert "hierarchy" in data + finally: + os.unlink(path) + + def test_relations_traverse_up(self, model): + path = self._ifc_path(model) + try: + wall = model.by_type("IfcWall")[0] + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", str(wall.id()), "--traverse", "up"], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + data = json.loads(result.stdout) + assert isinstance(data, list) + assert data[0]["type"] == "IfcWall" + assert data[-1]["type"] == "IfcProject" + finally: + os.unlink(path) + + def test_relations_bad_id(self, model): + path = self._ifc_path(model) + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", path, "relations", "999999"], + capture_output=True, + text=True, + ) + assert result.returncode != 0 + assert "Error" in result.stderr + finally: + os.unlink(path) diff --git a/src/ifcquery/tests/test_render.py b/src/ifcquery/tests/test_render.py new file mode 100644 index 0000000000..28d83a6b68 --- /dev/null +++ b/src/ifcquery/tests/test_render.py @@ -0,0 +1,355 @@ +# This file was generated with the assistance of an AI coding tool. +import os +import subprocess +import sys +import tempfile + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.context +import ifcopenshell.api.geometry +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.spatial +import ifcopenshell.api.unit +import ifcopenshell.guid +import numpy as np +import pytest + +from ifcquery.render import _make_profile_occurrence, _make_type_occurrence, render + +try: + import pyvista # noqa: F401 + + HAS_PYVISTA = True +except ImportError: + HAS_PYVISTA = False + +pytestmark = pytest.mark.skipif(not HAS_PYVISTA, reason="pyvista not installed") + +PNG_MAGIC = b"\x89PNG" + + +@pytest.fixture +def model_with_geometry(): + """Create an IFC4 model with walls that have geometric representations.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor") + + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + wall1 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001") + rep1 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=5, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall1, representation=rep1) + ifcopenshell.api.spatial.assign_container(f, products=[wall1], relating_structure=storey) + + wall2 = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall002") + rep2 = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=4, height=3, thickness=0.2) + ifcopenshell.api.geometry.assign_representation(f, product=wall2, representation=rep2) + ifcopenshell.api.spatial.assign_container(f, products=[wall2], relating_structure=storey) + matrix2 = np.eye(4) + matrix2[1, 3] = 3.0 + ifcopenshell.api.geometry.edit_object_placement(f, product=wall2, matrix=matrix2) + + return f + + +@pytest.fixture +def library_with_type(): + """IFC4 library file: a WallType with a RepresentationMap but no instances.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="LibProject") + ifcopenshell.api.unit.assign_unit(f) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + body = ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Build the shape representation and wrap it in an IfcRepresentationMap. + shape_rep = ifcopenshell.api.geometry.add_wall_representation(f, context=body, length=3, height=2.5, thickness=0.2) + origin = f.create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)) + z_dir = f.create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)) + x_dir = f.create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)) + map_origin = f.create_entity("IfcAxis2Placement3D", Location=origin, Axis=z_dir, RefDirection=x_dir) + rep_map = f.create_entity("IfcRepresentationMap", MappingOrigin=map_origin, MappedRepresentation=shape_rep) + + wall_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="LibWallType") + wall_type.RepresentationMaps = [rep_map] + + return f, wall_type + + +@pytest.fixture +def library_with_profile_type(): + """IFC4 library: a BeamType with an IfcMaterialProfileSet but no RepresentationMaps.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="ProfileLibProject") + ifcopenshell.api.unit.assign_unit(f) + + model_ctx = ifcopenshell.api.context.add_context(f, context_type="Model") + ifcopenshell.api.context.add_context( + f, context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model_ctx + ) + + # Rectangular profile 0.2m x 0.3m + profile = f.create_entity( + "IfcRectangleProfileDef", + ProfileType="AREA", + ProfileName="200x300", + XDim=0.2, + YDim=0.3, + ) + material = f.create_entity("IfcMaterial", Name="Steel") + mat_profile = f.create_entity("IfcMaterialProfile", Material=material, Profile=profile) + profile_set = f.create_entity("IfcMaterialProfileSet", MaterialProfiles=[mat_profile]) + + beam_type = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBeamType", name="200x300 Steel Beam") + rel = f.create_entity( + "IfcRelAssociatesMaterial", + GlobalId=ifcopenshell.guid.new(), + RelatedObjects=[beam_type], + RelatingMaterial=profile_set, + ) + + return f, beam_type + + +class TestRenderBasic: + def test_returns_png_bytes(self, model_with_geometry): + result = render(model_with_geometry) + assert isinstance(result, bytes) + assert result[:4] == PNG_MAGIC + + def test_iso_view(self, model_with_geometry): + result = render(model_with_geometry, view="iso") + assert result[:4] == PNG_MAGIC + + def test_top_view(self, model_with_geometry): + result = render(model_with_geometry, view="top") + assert result[:4] == PNG_MAGIC + + def test_south_view(self, model_with_geometry): + result = render(model_with_geometry, view="south") + assert result[:4] == PNG_MAGIC + + def test_unknown_view_falls_back_to_iso(self, model_with_geometry): + # Unknown view strings fall through to isometric + result = render(model_with_geometry, view="diagonal") + assert result[:4] == PNG_MAGIC + + +class TestRenderSelector: + def test_selector_restricts_elements(self, model_with_geometry): + result = render(model_with_geometry, selector="IfcWall") + assert result[:4] == PNG_MAGIC + + def test_selector_no_match_raises(self, model_with_geometry): + with pytest.raises(ValueError, match="matched no elements"): + render(model_with_geometry, selector="IfcDoor") + + +class TestRenderHighlight: + def test_highlight_single_element(self, model_with_geometry): + wall = model_with_geometry.by_type("IfcWall")[0] + result = render(model_with_geometry, element_ids=[wall.id()]) + assert result[:4] == PNG_MAGIC + + def test_highlight_multiple_elements(self, model_with_geometry): + walls = model_with_geometry.by_type("IfcWall") + result = render(model_with_geometry, element_ids=[w.id() for w in walls]) + assert result[:4] == PNG_MAGIC + + +class TestRenderTypes: + def test_render_type_by_selector(self, library_with_type): + """Selecting a type class renders its RepresentationMap geometry.""" + model, wall_type = library_with_type + result = render(model, selector="IfcWallType") + assert result[:4] == PNG_MAGIC + + def test_render_type_by_element_id(self, library_with_type): + """Passing a type step-ID via element_ids renders it highlighted.""" + model, wall_type = library_with_type + result = render(model, element_ids=[wall_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_original_model_unmodified(self, library_with_type): + """Rendering a type must not add entities to the original model.""" + model, wall_type = library_with_type + entity_count_before = len(list(model)) + render(model, selector="IfcWallType") + assert len(list(model)) == entity_count_before + + def test_make_type_occurrence_no_rep_maps(self, library_with_type): + """_make_type_occurrence returns None for a type with no RepresentationMaps.""" + model, _ = library_with_type + bare_type = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWallType", name="Bare") + assert _make_type_occurrence(model, bare_type) is None + + def test_type_without_rep_maps_raises(self): + """Selecting a type that has no RepresentationMaps raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + ifcopenshell.api.root.create_entity(f, ifc_class="IfcWallType", name="Bare") + with pytest.raises(ValueError): + render(f, selector="IfcWallType") + + +class TestRenderProfileTypes: + def test_render_profile_type_by_element_id(self, library_with_profile_type): + """A type with only a material profile set renders via temporary extrusion.""" + model, beam_type = library_with_profile_type + result = render(model, element_ids=[beam_type.id()]) + assert result[:4] == PNG_MAGIC + + def test_make_profile_occurrence_creates_occurrence(self, library_with_profile_type): + """_make_profile_occurrence returns an occurrence entity for a profile-set type.""" + model, beam_type = library_with_profile_type + occ = _make_profile_occurrence(model, beam_type) + assert occ is not None + + def test_make_profile_occurrence_no_profile_returns_none(self, library_with_type): + """_make_profile_occurrence returns None when type has no material profile set.""" + model, wall_type = library_with_type + # wall_type has RepresentationMaps but no material profile set + occ = _make_profile_occurrence(model, wall_type) + assert occ is None + + def test_original_model_unmodified_for_profile_type(self, library_with_profile_type): + """Rendering a profile-based type does not modify the original model.""" + model, beam_type = library_with_profile_type + entity_count_before = len(list(model)) + render(model, element_ids=[beam_type.id()]) + assert len(list(model)) == entity_count_before + + +class TestRenderNoGeometry: + def test_no_geometry_raises(self): + """A model without geometry representations raises ValueError.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="P") + ifcopenshell.api.unit.assign_unit(f) + site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="S") + building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="B") + storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="GF") + ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project) + ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site) + ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building) + wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wallless") + ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey) + + with pytest.raises(ValueError, match="No renderable geometry"): + render(f) + + +class TestCLI: + @staticmethod + def _ifc_path(model): + f = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + model.write(f.name) + f.close() + return f.name + + def test_render_writes_png(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_out.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(out_path) + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_default_output_path(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + expected_png = ifc_path.replace(".ifc", ".png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + assert os.path.exists(expected_png) + finally: + for path in (ifc_path, expected_png): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_selector(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_sel.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--selector", "IfcWall"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass + + def test_render_with_view(self, model_with_geometry): + ifc_path = self._ifc_path(model_with_geometry) + out_path = ifc_path.replace(".ifc", "_top.png") + try: + result = subprocess.run( + [sys.executable, "-m", "ifcquery", ifc_path, "render", "-o", out_path, "--view", "top"], + capture_output=True, + text=True, + ) + assert result.returncode == 0, result.stderr + with open(out_path, "rb") as f: + assert f.read(4) == PNG_MAGIC + finally: + for path in (ifc_path, out_path): + try: + os.unlink(path) + except OSError: + pass diff --git a/src/ifcquery/tests/test_schedule.py b/src/ifcquery/tests/test_schedule.py new file mode 100644 index 0000000000..0d3a3e934f --- /dev/null +++ b/src/ifcquery/tests/test_schedule.py @@ -0,0 +1,121 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.aggregate +import ifcopenshell.api.owner.settings +import ifcopenshell.api.project +import ifcopenshell.api.root +import ifcopenshell.api.sequence +import ifcopenshell.api.unit +import pytest + +from ifcquery.schedule import schedule + + +@pytest.fixture +def schedule_model(): + """Create an IFC4 model with a work schedule and nested tasks.""" + f = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + + project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject") + ifcopenshell.api.unit.assign_unit(f) + + ws = ifcopenshell.api.sequence.add_work_schedule(f, name="Construction Schedule") + + task1 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 1", identification="P1") + tt1 = ifcopenshell.api.sequence.add_task_time(f, task=task1) + ifcopenshell.api.sequence.edit_task_time( + f, task_time=tt1, attributes={"ScheduleStart": "2024-01-01", "ScheduleFinish": "2024-06-30"} + ) + + task2 = ifcopenshell.api.sequence.add_task(f, work_schedule=ws, name="Phase 2", identification="P2") + subtask = ifcopenshell.api.sequence.add_task(f, parent_task=task1, name="Sub Task", identification="S1") + + return f + + +class TestSchedule: + def test_returns_list(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result, list) + + def test_finds_work_schedule(self, schedule_model): + result = schedule(schedule_model) + assert len(result) == 1 + + def test_work_schedule_has_name(self, schedule_model): + result = schedule(schedule_model) + assert result[0]["name"] == "Construction Schedule" + + def test_work_schedule_has_id(self, schedule_model): + result = schedule(schedule_model) + assert isinstance(result[0]["id"], int) + assert result[0]["id"] > 0 + + def test_work_schedule_has_tasks(self, schedule_model): + result = schedule(schedule_model) + tasks = result[0]["tasks"] + assert isinstance(tasks, list) + assert len(tasks) >= 1 + + def test_task_has_required_fields(self, schedule_model): + result = schedule(schedule_model) + task = result[0]["tasks"][0] + assert "id" in task + assert "name" in task + assert "start" in task + assert "finish" in task + assert "is_milestone" in task + assert "outputs" in task + assert "subtasks" in task + + def test_task_name(self, schedule_model): + result = schedule(schedule_model) + task_names = [t["name"] for t in result[0]["tasks"]] + assert "Phase 1" in task_names + + def test_task_start_finish(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert phase1["start"] is not None + assert phase1["finish"] is not None + + def test_subtasks(self, schedule_model): + result = schedule(schedule_model) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert len(phase1["subtasks"]) == 1 + assert phase1["subtasks"][0]["name"] == "Sub Task" + + def test_empty_model_returns_empty_list(self, model): + result = schedule(model) + assert result == [] + + def test_max_depth_none_returns_full_tree(self, schedule_model): + result = schedule(schedule_model, max_depth=None) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], list) + assert len(phase1["subtasks"]) == 1 + + def test_max_depth_1_truncates_subtasks(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + assert isinstance(phase1["subtasks"], dict) + assert phase1["subtasks"]["truncated"] is True + assert phase1["subtasks"]["count"] == 1 + + def test_max_depth_truncation_shows_count(self, schedule_model): + result = schedule(schedule_model, max_depth=1) + # Phase 2 has no subtasks — should return empty list, not truncation dict + phase2 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 2") + assert phase2["subtasks"] == [] + + def test_max_depth_2_expands_to_depth_2(self, schedule_model): + result = schedule(schedule_model, max_depth=2) + phase1 = next(t for t in result[0]["tasks"] if t["name"] == "Phase 1") + # subtask at depth 2 should be fully expanded (it has no children) + assert isinstance(phase1["subtasks"], list) + assert phase1["subtasks"][0]["name"] == "Sub Task" + assert phase1["subtasks"][0]["subtasks"] == [] diff --git a/src/ifcquery/tests/test_schema.py b/src/ifcquery/tests/test_schema.py new file mode 100644 index 0000000000..c4f63344ee --- /dev/null +++ b/src/ifcquery/tests/test_schema.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import pytest + +from ifcquery.schema import schema + + +class TestSchema: + def test_ifc_wall_has_description(self, model): + result = schema(model, "IfcWall") + assert "description" in result + assert isinstance(result["description"], str) + assert len(result["description"]) > 0 + + def test_ifc_wall_has_attributes(self, model): + result = schema(model, "IfcWall") + assert "attributes" in result + + def test_ifc_wall_has_spec_url(self, model): + result = schema(model, "IfcWall") + assert "spec_url" in result + + def test_unknown_entity_returns_error(self, model): + result = schema(model, "IfcNonExistentFooBar") + assert "error" in result + assert "IfcNonExistentFooBar" in result["error"] + + def test_ifc_window_has_description(self, model): + result = schema(model, "IfcWindow") + assert "description" in result + assert len(result["description"]) > 0 diff --git a/src/ifcquery/tests/test_select.py b/src/ifcquery/tests/test_select.py new file mode 100644 index 0000000000..d5700a38e6 --- /dev/null +++ b/src/ifcquery/tests/test_select.py @@ -0,0 +1,32 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.select import select + + +class TestSelect: + def test_select_by_type(self, model): + result = select(model, "IfcWall") + assert len(result) == 1 + assert result[0]["type"] == "IfcWall" + assert result[0]["name"] == "Wall001" + + def test_select_multiple_types(self, model): + result = select(model, "IfcWall, IfcSlab") + assert len(result) == 2 + types = {r["type"] for r in result} + assert types == {"IfcWall", "IfcSlab"} + + def test_select_no_match(self, model): + result = select(model, "IfcDoor") + assert result == [] + + def test_results_sorted_by_id(self, model): + result = select(model, "IfcWall, IfcSlab") + ids = [r["id"] for r in result] + assert ids == sorted(ids) + + def test_result_has_id_type_name(self, model): + result = select(model, "IfcWall") + entry = result[0] + assert "id" in entry + assert "type" in entry + assert "name" in entry diff --git a/src/ifcquery/tests/test_summary.py b/src/ifcquery/tests/test_summary.py new file mode 100644 index 0000000000..c230b48ab7 --- /dev/null +++ b/src/ifcquery/tests/test_summary.py @@ -0,0 +1,34 @@ +# This file was generated with the assistance of an AI coding tool. +import ifcopenshell +import ifcopenshell.api.project + +from ifcquery.summary import summary + + +class TestSummary: + def test_schema(self, model): + result = summary(model) + assert result["schema"] == "IFC4" + + def test_total_entities(self, model): + result = summary(model) + assert result["total_entities"] == len(list(model)) + assert result["total_entities"] > 0 + + def test_project_info(self, model): + result = summary(model) + assert result["project"]["name"] == "TestProject" + + def test_type_counts(self, model): + result = summary(model) + types = result["types"] + assert "IfcWall" in types + assert types["IfcWall"] == 1 + assert "IfcSlab" in types + assert types["IfcSlab"] == 1 + + def test_empty_model(self): + f = ifcopenshell.api.project.create_file() + result = summary(f) + assert result["schema"] == "IFC4" + assert "project" not in result diff --git a/src/ifcquery/tests/test_tree.py b/src/ifcquery/tests/test_tree.py new file mode 100644 index 0000000000..2110be3188 --- /dev/null +++ b/src/ifcquery/tests/test_tree.py @@ -0,0 +1,37 @@ +# This file was generated with the assistance of an AI coding tool. +from ifcquery.tree import tree + + +class TestTree: + def test_root_is_project(self, model): + result = tree(model) + assert result["type"] == "IfcProject" + assert result["name"] == "TestProject" + + def test_spatial_hierarchy(self, model): + result = tree(model) + # Project > Site > Building > Storey + site = result["children"][0] + assert site["type"] == "IfcSite" + assert site["name"] == "TestSite" + + building = site["children"][0] + assert building["type"] == "IfcBuilding" + assert building["name"] == "TestBuilding" + + storey = building["children"][0] + assert storey["type"] == "IfcBuildingStorey" + assert storey["name"] == "Ground Floor" + + def test_contained_elements(self, model): + result = tree(model) + storey = result["children"][0]["children"][0]["children"][0] + elements = storey["elements"] + element_types = {e["type"] for e in elements} + assert "IfcWall" in element_types + assert "IfcSlab" in element_types + + def test_element_ids_present(self, model): + result = tree(model) + assert "id" in result + assert isinstance(result["id"], int) diff --git a/src/ifcquery/tests/test_validate.py b/src/ifcquery/tests/test_validate.py new file mode 100644 index 0000000000..44734ce4b0 --- /dev/null +++ b/src/ifcquery/tests/test_validate.py @@ -0,0 +1,47 @@ +# This file was generated with the assistance of an AI coding tool. +from __future__ import annotations + +import ifcopenshell +import ifcopenshell.api.project +import pytest + +from ifcquery.validate import validate + + +class TestValidate: + def test_valid_model_returns_valid_true(self, model): + result = validate(model) + assert result["valid"] is True + assert isinstance(result["issues"], list) + + def test_valid_model_has_no_issues(self, model): + result = validate(model) + assert result["issues"] == [] + + def test_empty_model_is_valid(self): + f = ifcopenshell.api.project.create_file() + result = validate(f) + assert result["valid"] is True + assert result["issues"] == [] + + def test_result_has_expected_keys(self, model): + result = validate(model) + assert "valid" in result + assert "issues" in result + + def test_express_rules_flag_accepted(self, model): + # Just verify it runs without error; express rules may add/not add issues + result = validate(model, express_rules=True) + assert "valid" in result + assert isinstance(result["issues"], list) + + def test_issue_has_level_and_message(self, model): + # Force an issue by manually breaking the model (invalid IfcWall attribute) + f = ifcopenshell.file() + # Create a raw IfcWall with deliberately wrong type for GlobalId (use int) + # We just check structure if any issues appear; on well-formed models there are none. + result = validate(model) + # Even if no issues, the structure contract must hold for any issues present + for issue in result["issues"]: + assert "level" in issue + assert "message" in issue diff --git a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py index 785d5db718..134ad05d99 100644 --- a/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/bmesh_to_ifc.py @@ -193,7 +193,7 @@ class SvIfcBMeshToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.help blender_object=obj, geometry=obj.data, context=context, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] ) if not representation: raise Exception("Couldn't create representation. Possibly wrong context.") diff --git a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py index 1725f37aa2..350f4ac71f 100644 --- a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py +++ b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py @@ -32,7 +32,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv bl_idname = "SvIfcSbExtrude" bl_label = "IFC Extrude" - extrude_axis: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] + extrude_axis: bpy.props.EnumProperty( default="Z", items=[ ("X", "X", "Interpret curve as in XY plane and extrude along X+."), diff --git a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py index be06fac1ce..eb0965b8b3 100644 --- a/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py +++ b/src/ifcsverchok/nodes/ifc/sverchok_to_ifc.py @@ -143,7 +143,7 @@ class SvIfcSverchokToIfcRepr(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.h for item in obj: representation = ifcopenshell.api.geometry.add_mesh_representation( self.file, - should_run_listeners=False, + should_run_listeners=False, # ty:ignore[unknown-argument] context=self.context, vertices=[list(map(tuple, item[0]))], edges=[list(map(tuple, item[1]))], diff --git a/src/ifctester/Makefile b/src/ifctester/Makefile index a2df0c5820..71ecbd48d7 100644 --- a/src/ifctester/Makefile +++ b/src/ifctester/Makefile @@ -25,9 +25,19 @@ WEBAPP_BUILD_DIR := $(WEBAPP_DIR)/dist PYODIDE_DIR := $(WEBAPP_DIR)/public/pyodide PYODIDE_VERSION := 0.28.0 PYODIDE_URL := https://github.com/pyodide/pyodide/releases/download/$(PYODIDE_VERSION)/pyodide-$(PYODIDE_VERSION).tar.bz2 +WORKER_BIN_DIR := $(WEBAPP_DIR)/public/worker/bin +IFCOPENSHELL_WASM_WHEEL := ifcopenshell-0.8.5+a51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl +IFCOPENSHELL_WASM_WHEEL_URL := https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-0.8.5%2Ba51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl +IFCOPENSHELL_WASM_WHEEL_PATH := $(WORKER_BIN_DIR)/$(IFCOPENSHELL_WASM_WHEEL) +WEBAPP_GENERATED_DIR := $(WEBAPP_DIR)/public/worker/generated +WEBAPP_IFCTESTER_MANIFEST := $(WEBAPP_GENERATED_DIR)/ifctester.json +WEBAPP_IFCTESTER_BUILD_DIR := build-webapp-wheel +PACKAGE_WEBAPP_DIR := $(PACKAGE_NAME)/webapp +PACKAGE_WEBAPP_WWW_DIR := $(PACKAGE_WEBAPP_DIR)/www .PHONY: webapp-dev -webapp-dev: +webapp-dev: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel + cd $(WEBAPP_DIR) && npm install cd $(WEBAPP_DIR) && npm run dev .PHONY: pyodide-download @@ -62,8 +72,43 @@ pyodide-download: echo "Pyodide $(PYODIDE_VERSION) prepared in $(PYODIDE_DIR)"; \ fi +.PHONY: ifcopenshell-wasm-download +ifcopenshell-wasm-download: + @if [ -f "$(IFCOPENSHELL_WASM_WHEEL_PATH)" ]; then \ + echo "IfcOpenShell wasm wheel already exists at $(IFCOPENSHELL_WASM_WHEEL_PATH), skipping download"; \ + else \ + echo "Downloading IfcOpenShell wasm wheel..."; \ + mkdir -p $(WORKER_BIN_DIR); \ + rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl; \ + curl -fL -o "$(IFCOPENSHELL_WASM_WHEEL_PATH)" "$(IFCOPENSHELL_WASM_WHEEL_URL)"; \ + echo "IfcOpenShell wasm wheel prepared at $(IFCOPENSHELL_WASM_WHEEL_PATH)"; \ + fi + +.PHONY: webapp-stage-ifctester-wheel +webapp-stage-ifctester-wheel: + rm -rf $(WEBAPP_GENERATED_DIR) + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) + mkdir -p $(WEBAPP_IFCTESTER_BUILD_DIR) + cp -r $(PACKAGE_NAME) $(WEBAPP_IFCTESTER_BUILD_DIR)/ + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/webapp + cp pyproject.toml $(WEBAPP_IFCTESTER_BUILD_DIR)/ + cp README.md $(WEBAPP_IFCTESTER_BUILD_DIR)/ +ifeq ($(IS_STABLE), TRUE) + $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml + $(SED) 's/version = "0.0.0"/version = "$(VERSION)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py +else + $(SED) 's/version = "0.0.0"/version = "$(VERSION)a$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/pyproject.toml + $(SED) 's/version = "0.0.0"/version = "$(VERSION)-alpha$(VERSION_DATE)"/' $(WEBAPP_IFCTESTER_BUILD_DIR)/$(PACKAGE_NAME)/__init__.py +endif + cd $(WEBAPP_IFCTESTER_BUILD_DIR) && $(PYTHON) -m venv env --system-site-packages && . env/$(VENV_ACTIVATE) && python -m pip install build && python -m build --wheel --no-isolation + mkdir -p $(WEBAPP_GENERATED_DIR) + wheel=$$(basename $(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$(PACKAGE_NAME)-*.whl); \ + cp "$(WEBAPP_IFCTESTER_BUILD_DIR)/dist/$$wheel" "$(WEBAPP_GENERATED_DIR)/$$wheel"; \ + printf '{\n "wheel_url": "/worker/generated/%s"\n}\n' "$$wheel" > "$(WEBAPP_IFCTESTER_MANIFEST)" + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) + .PHONY: webapp-build -webapp-build: pyodide-download +webapp-build: pyodide-download ifcopenshell-wasm-download webapp-stage-ifctester-wheel cd $(WEBAPP_DIR) && npm install cd $(WEBAPP_DIR) && npm run build @@ -76,23 +121,36 @@ clean: rm -rf $(WEBAPP_BUILD_DIR) rm -rf $(WEBAPP_DIR)/node_modules rm -rf $(PYODIDE_DIR) + rm -f $(WORKER_BIN_DIR)/ifcopenshell-*.whl + rm -rf $(WEBAPP_GENERATED_DIR) + rm -rf $(WEBAPP_IFCTESTER_BUILD_DIR) rm -rf $(PACKAGE_NAME)/webapp rm -rf dist -.PHONY: dist -dist: webapp-prepare +.PHONY: python-dist +python-dist: + rm -rf dist # For some reason OS is not initalized when we call common.mk dist, which matters on Windows. # So we pass it explicitly. - $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS) + $(MAKE) -f ../common.mk dist PACKAGE_NAME=$(PACKAGE_NAME) OS=$(OS) IS_STABLE=$(IS_STABLE) + +.PHONY: dist +dist: webapp-prepare + $(MAKE) python-dist OS=$(OS) IS_STABLE=$(IS_STABLE) .PHONY: webapp-prepare webapp-prepare: webapp-build - rm -rf $(PACKAGE_NAME)/webapp/www/* - mkdir -p $(PACKAGE_NAME)/webapp/www - cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_NAME)/webapp/www/ - cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_NAME)/webapp/__init__.py - cp $(WEBAPP_DIR)/serve.py $(PACKAGE_NAME)/webapp/serve.py + rm -rf $(PACKAGE_WEBAPP_WWW_DIR) + mkdir -p $(PACKAGE_WEBAPP_WWW_DIR) + cp -r $(WEBAPP_BUILD_DIR)/* $(PACKAGE_WEBAPP_WWW_DIR)/ + cp $(WEBAPP_DIR)/__init__.py $(PACKAGE_WEBAPP_DIR)/__init__.py + cp $(WEBAPP_DIR)/serve.py $(PACKAGE_WEBAPP_DIR)/serve.py .PHONY: test test: pytest -p no:pytest-blender test + +.PHONY: build-ids-docs +build-ids-docs: + mkdir -p test/build + cd test && python ids_doc_generator.py diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index 3788afebbc..f91decc1a7 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -16,6 +16,16 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcTester. If not, see . +""" +Documentation generator for IfcTester IDS facets and test cases. + +This is not a test file. It lives in the test/ directory because it reuses +test cases from test_facet.py and test_ids.py to generate example IFC files, +IDS files, and Markdown documentation into test/build/. + +Run via: make build-ids-docs (from src/ifctester/) +""" + import functools import os import re @@ -40,6 +50,7 @@ import test_ids from ifcopenshell import validate import ifctester +import ifctester.facet from ifctester import ids outdir = "build" @@ -117,6 +128,8 @@ class FacetDocGenerator: {"name": name, "ids": xml_text, "ifc": ifc_text, "basename": basename, "result": result, "id": inst.id()} ) + ifctester.facet.get_pset.cache_clear() + ifctester.facet.get_psets.cache_clear() assert bool(facet(inst)) is expected def set_facet(self, facet): @@ -140,7 +153,7 @@ class IdsDocGenerator: all_applicable.update(spec.applicable_entities) for requirement in spec.requirements: if requirement.status is False: - all_failures.update(requirement.failed_entities) + all_failures.update(f["element"] for f in requirement.failures) assert set(all_applicable) == set(applicable_entities) assert set(all_failures) == set(failed_entities) @@ -151,7 +164,11 @@ class IdsDocGenerator: l = validate.json_logger() validate.validate(ifc, l) for issue in l.statements: - raise Exception("About to emit invalid example data:", issue) + # test_parsing_entities_with_no_attributes uses nameless IfcMaterial; fix for doc generation. + if issue["instance"].is_a("IfcMaterial") and issue.get("attribute") == "IfcMaterial.Name": + issue["instance"].Name = "Unnamed" + else: + raise Exception("About to emit invalid example data:", issue) lines = ifc.wrapped_data.to_string().split("\n")[7:-3] ifc_text = "" @@ -293,12 +310,12 @@ spec = ifctester.ids.Specification( ) specs.specifications.append(spec) spec.applicability.append(ifctester.ids.Entity(name="IFCWALLTYPE")) -restriction = ifctester.ids.Restriction(options={"pattern": "(-|[0-9]{2,3})\/(-|[0-9]{2,3})\/(-|[0-9]{2,3})"}) +restriction = ifctester.ids.Restriction(options={"pattern": r"(-|[0-9]{2,3})/(-|[0-9]{2,3})/(-|[0-9]{2,3})"}) spec.requirements.append( ifctester.ids.Property( propertySet="Pset_WallCommon", - name="FireRating", - datatype="IfcLabel", + baseName="FireRating", + dataType="IfcLabel", value=restriction, instructions="Fire rating is specified using the Fire Resistance Level as defined in the Australian National Construction Code (NCC) 2019. Valid examples include -/-/-, -/120/120, and 60/60/60", ) diff --git a/src/ifctester/webapp/.gitignore b/src/ifctester/webapp/.gitignore index 4205867691..f7daf321ef 100644 --- a/src/ifctester/webapp/.gitignore +++ b/src/ifctester/webapp/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +public/worker/bin/ifcopenshell-*.whl +public/worker/generated # Editor directories and files .vscode/* @@ -24,4 +26,4 @@ dist-ssr *.sw? .claude -experiment/* \ No newline at end of file +experiment/* diff --git a/src/ifctester/webapp/biome.json b/src/ifctester/webapp/biome.json new file mode 100644 index 0000000000..ce01c80c3f --- /dev/null +++ b/src/ifctester/webapp/biome.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": [ + "dist", + "node_modules", + "public/pyodide", + "**/*.svelte" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "overrides": [ + { + "include": [ + "src/modules/wasm/worker/**" + ], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ] +} diff --git a/src/ifctester/webapp/index.html b/src/ifctester/webapp/index.html index 27c32440dc..48ac4cc1c8 100644 --- a/src/ifctester/webapp/index.html +++ b/src/ifctester/webapp/index.html @@ -9,6 +9,6 @@
    - + diff --git a/src/ifctester/webapp/jsconfig.json b/src/ifctester/webapp/jsconfig.json deleted file mode 100644 index 2aef2313e4..0000000000 --- a/src/ifctester/webapp/jsconfig.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "compilerOptions": { - "moduleResolution": "bundler", - "target": "ESNext", - "module": "ESNext", - /** - * svelte-preprocess cannot figure out whether you have - * a value or a type, so tell TypeScript to enforce using - * `import type` instead of `import` for Types. - */ - "verbatimModuleSyntax": true, - "isolatedModules": true, - "resolveJsonModule": true, - /** - * To have warnings / errors of the Svelte compiler at the - * correct position, enable source maps by default. - */ - "sourceMap": true, - "esModuleInterop": true, - "skipLibCheck": true, - /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable this if you'd like to use dynamic types. - */ - "checkJs": false, - "baseUrl": ".", - "paths": { - "$lib": ["./src/lib"], - "$lib/*": ["./src/lib/*"], - "$src": ["./src"], - "$src/*": ["./src/*"] - } - }, - /** - * Use global.d.ts instead of compilerOptions.types - * to avoid limiting type declarations. - */ - "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 1f567798a8..90a6743443 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -16,6 +16,7 @@ "svelte-spa-router": "^4.0.1" }, "devDependencies": { + "@biomejs/biome": "^1.9.4", "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.515.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", @@ -25,12 +26,14 @@ "mode-watcher": "^1.1.0", "sass-embedded": "^1.89.0", "svelte": "^5.53.6", + "svelte-check": "^4.0.0", "svelte-sonner": "^1.0.5", "tailwind-merge": "^3.3.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.3.2", - "vite": "^6.4.1" + "typescript": "^5.8.3", + "vite": "^6.4.2" } }, "node_modules/@ampproject/remapping": { @@ -47,6 +50,170 @@ "node": ">=6.0.0" } }, + "node_modules/@biomejs/biome": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-1.9.4.tgz", + "integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "1.9.4", + "@biomejs/cli-darwin-x64": "1.9.4", + "@biomejs/cli-linux-arm64": "1.9.4", + "@biomejs/cli-linux-arm64-musl": "1.9.4", + "@biomejs/cli-linux-x64": "1.9.4", + "@biomejs/cli-linux-x64-musl": "1.9.4", + "@biomejs/cli-win32-arm64": "1.9.4", + "@biomejs/cli-win32-x64": "1.9.4" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz", + "integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-1.9.4.tgz", + "integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-1.9.4.tgz", + "integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-1.9.4.tgz", + "integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", + "integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz", + "integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-1.9.4.tgz", + "integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-1.9.4.tgz", + "integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.5.1.tgz", @@ -1468,6 +1635,22 @@ "dev": true, "license": "MIT/X11" }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -1498,7 +1681,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2127,6 +2309,16 @@ "svelte": "^5.7.0" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2160,9 +2352,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2201,6 +2393,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/regexparam": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", @@ -2282,6 +2488,19 @@ "tslib": "^2.1.0" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/sass-embedded": { "version": "1.89.0", "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.89.0.tgz", @@ -2700,35 +2919,18 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", - "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1" + "debug": "~4.4.1" }, "engines": { "node": ">=10.0.0" } }, - "node_modules/socket.io-parser/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2801,6 +3003,30 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, "node_modules/svelte-sonner": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.5.tgz", @@ -3001,6 +3227,20 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", @@ -3024,9 +3264,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/ifctester/webapp/package.json b/src/ifctester/webapp/package.json index bb7d58567e..f36baf96ec 100644 --- a/src/ifctester/webapp/package.json +++ b/src/ifctester/webapp/package.json @@ -6,11 +6,15 @@ "scripts": { "dev": "vite", "build": "vite build", + "check": "tsc -p tsconfig.json --noEmit && svelte-check && biome lint .", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", "preview": "vite preview", "deploy": "npm run build && npx wrangler pages deploy dist" }, "devDependencies": { "@internationalized/date": "^3.8.1", + "@biomejs/biome": "^1.9.4", "@lucide/svelte": "^0.515.0", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@tailwindcss/vite": "^4.0.0", @@ -19,12 +23,14 @@ "mode-watcher": "^1.1.0", "sass-embedded": "^1.89.0", "svelte": "^5.53.6", + "svelte-check": "^4.0.0", "svelte-sonner": "^1.0.5", "tailwind-merge": "^3.3.0", "tailwind-variants": "^1.0.0", "tailwindcss": "^4.0.0", + "typescript": "^5.8.3", "tw-animate-css": "^1.3.2", - "vite": "^6.4.1" + "vite": "^6.4.2" }, "dependencies": { "eventemitter3": "^5.0.1", diff --git a/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl b/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl deleted file mode 100644 index 703ae3b877..0000000000 Binary files a/src/ifctester/webapp/public/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl and /dev/null differ diff --git a/src/ifctester/webapp/serve.py b/src/ifctester/webapp/serve.py index 5b18f15545..c114b47542 100644 --- a/src/ifctester/webapp/serve.py +++ b/src/ifctester/webapp/serve.py @@ -22,9 +22,6 @@ import os import sys bonsai_lib_path = os.environ.get("BONSAI_LIB_PATH") -print(os.environ) -print(bonsai_lib_path) -bonsai_version = os.environ.get("BONSAI_VERSION") if bonsai_lib_path: sys.path.insert(0, bonsai_lib_path) diff --git a/src/ifctester/webapp/src/App.svelte b/src/ifctester/webapp/src/App.svelte index 19222ad8d1..27b5c8001b 100644 --- a/src/ifctester/webapp/src/App.svelte +++ b/src/ifctester/webapp/src/App.svelte @@ -1,4 +1,4 @@ - diff --git a/src/ifctester/webapp/src/app.d.ts b/src/ifctester/webapp/src/app.d.ts new file mode 100644 index 0000000000..ff86489626 --- /dev/null +++ b/src/ifctester/webapp/src/app.d.ts @@ -0,0 +1,4 @@ +declare module "*.svelte" { + import type { SvelteComponent } from "svelte"; + export default class Component extends SvelteComponent {} +} diff --git a/src/ifctester/webapp/src/components/AppHeader.svelte b/src/ifctester/webapp/src/components/AppHeader.svelte index 61f8a5b3b2..8fb71b6180 100644 --- a/src/ifctester/webapp/src/components/AppHeader.svelte +++ b/src/ifctester/webapp/src/components/AppHeader.svelte @@ -1,11 +1,11 @@ - @@ -142,4 +143,4 @@ - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/AppRibbon.svelte b/src/ifctester/webapp/src/components/AppRibbon.svelte index 4f9643637e..662a93b500 100644 --- a/src/ifctester/webapp/src/components/AppRibbon.svelte +++ b/src/ifctester/webapp/src/components/AppRibbon.svelte @@ -1,6 +1,5 @@ -
    @@ -20,4 +19,4 @@ Error
    {/if} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/AppToolbar.svelte b/src/ifctester/webapp/src/components/AppToolbar.svelte index 3552b13969..c9b3dd2968 100644 --- a/src/ifctester/webapp/src/components/AppToolbar.svelte +++ b/src/ifctester/webapp/src/components/AppToolbar.svelte @@ -1,14 +1,16 @@ - @@ -63,4 +65,4 @@ Part Of - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/components/IdsTabs.svelte b/src/ifctester/webapp/src/components/IdsTabs.svelte index 0f6f38a569..25d6891ab7 100644 --- a/src/ifctester/webapp/src/components/IdsTabs.svelte +++ b/src/ifctester/webapp/src/components/IdsTabs.svelte @@ -1,13 +1,22 @@ -
    @@ -15,10 +24,13 @@
    switchDocument(docId)} - aria-label={doc.info.title || "Untitled"} + onkeydown={(event) => handleActivation(event, () => switchDocument(docId))} + aria-label={(doc as IdsDocument).info.title || "Untitled"} > - {doc.info.title || "Untitled"} + {(doc as IdsDocument).info.title || "Untitled"}
    {/each}
    -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/config.json b/src/ifctester/webapp/src/config.json index d5c450f460..f4f469178f 100644 --- a/src/ifctester/webapp/src/config.json +++ b/src/ifctester/webapp/src/config.json @@ -1,8 +1,7 @@ { "wasm": { - "wheel_url": "/worker/bin/ifcopenshell-0.8.3+bb329af-cp313-cp313-emscripten_4_0_9_wasm32.whl", + "wheel_url": "/worker/bin/ifcopenshell-0.8.5+a51b2c5-cp313-cp313-pyodide_2025_0_wasm32.whl", "odfpy_url": "/worker/bin/odfpy-1.4.2-py2.py3-none-any.whl", - "api_py_url": "/worker/api.py", - "pyodide_url": "https://cdn.jsdelivr.net/pyodide/v0.28.0/full/pyodide.js" + "api_py_url": "/worker/api.py" } -} \ No newline at end of file +} diff --git a/src/ifctester/webapp/src/css/app.css b/src/ifctester/webapp/src/css/app.css index a8210b43e8..09f12f3b9f 100644 --- a/src/ifctester/webapp/src/css/app.css +++ b/src/ifctester/webapp/src/css/app.css @@ -576,7 +576,8 @@ html, body { display: flex; flex-direction: column; - label { + label, + .form-label { margin-bottom: 4px; font-size: 14px; font-weight: 500; diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte index 94266a6315..a6ac3f979a 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte index 9ceaa7faf7..17d98fe392 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,8 +1,17 @@ - @@ -35,4 +44,4 @@ {/if} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte index 419a27e449..0f91cf0670 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-description.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte index b2d4035e6e..7071ae2a0c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-footer.svelte @@ -1,11 +1,19 @@ -
    {@render children?.()} -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte index 791dcde5d7..9bb73aa601 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-header.svelte @@ -1,12 +1,19 @@ -
    {@render children?.()} -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte index adb8a38e94..90e3104148 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte index 2d36371d01..866d5ef826 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-title.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte index 7a38ff9e6a..5636ea2df1 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dialog/index.js b/src/ifctester/webapp/src/lib/components/ui/dialog/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/dialog/index.js rename to src/ifctester/webapp/src/lib/components/ui/dialog/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index fc034efd31..242f3088ef 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,8 +1,16 @@ - {@render childrenProp?.()} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index d13c1ad08d..b793c23e85 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,14 +1,21 @@ - @@ -22,4 +29,4 @@ )} {...restProps} /> - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte index 89454b26df..83032fee1e 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte index 4f3421fbe8..55220a4783 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index a06f2099dd..20744df288 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,14 +1,21 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte index e0aceee7d3..2fc90b933b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,12 +1,20 @@ -
    {@render children?.()} -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte index 009a4dd02d..0be2895631 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -1,11 +1,16 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte index 218db7f78a..629cc37563 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -1,14 +1,22 @@ - {@render childrenProp?.({ checked })} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte index 4d02884d17..533e47c574 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte index 20f2210c5a..93b6175647 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -1,12 +1,19 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte index 0f3c698fea..7a33ac199c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte index e4e236e111..a2be23a136 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -1,7 +1,15 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte index 720d196b77..1aa23bc512 100644 --- a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.js b/src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.js rename to src/ifctester/webapp/src/lib/components/ui/dropdown-menu/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/index.js b/src/ifctester/webapp/src/lib/components/ui/menubar/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/menubar/index.js rename to src/ifctester/webapp/src/lib/components/ui/menubar/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte index 0d6e298b0f..bb020eec7b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-checkbox-item.svelte @@ -1,8 +1,16 @@ - {@render childrenProp?.()} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte index 2706fc76e2..4c7696d218 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-content.svelte @@ -1,6 +1,19 @@ - @@ -28,4 +41,4 @@ )} {...restProps} /> - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte index c64dd91534..13417d54ec 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group-heading.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte index 8acc8b9549..2c77293f76 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-group.svelte @@ -1,10 +1,14 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte index f4fee4711e..1ff76231a5 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-item.svelte @@ -1,6 +1,13 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte index 129be16dd5..efbbb9b017 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-label.svelte @@ -1,12 +1,19 @@ -
    {@render children?.()} -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte index c7c122881f..1947458beb 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-radio-item.svelte @@ -1,14 +1,22 @@ - {@render childrenProp?.({ checked })} {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte index d32bbab673..7674b560bc 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-separator.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte index 7df7a0d118..05cf1bc13b 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-shortcut.svelte @@ -1,12 +1,19 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte index 5a79de2c58..67b68ca633 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-content.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte index f9fa0461ee..c14976bd12 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-sub-trigger.svelte @@ -1,7 +1,15 @@ - {@render children?.()} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte index c39921a2a1..33119d913c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar-trigger.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte index cddff1b908..ebc3534d09 100644 --- a/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/menubar/menubar.svelte @@ -1,12 +1,17 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/sonner/index.js b/src/ifctester/webapp/src/lib/components/ui/sonner/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/sonner/index.js rename to src/ifctester/webapp/src/lib/components/ui/sonner/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte b/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte index 91981de7e6..28a794ca0f 100644 --- a/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/sonner/sonner.svelte @@ -1,8 +1,8 @@ - \ No newline at end of file +/> diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/index.js b/src/ifctester/webapp/src/lib/components/ui/tooltip/index.ts similarity index 100% rename from src/ifctester/webapp/src/lib/components/ui/tooltip/index.js rename to src/ifctester/webapp/src/lib/components/ui/tooltip/index.ts diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte index 3c25c65b9a..6db2203857 100644 --- a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -1,6 +1,18 @@ - @@ -42,4 +54,4 @@ {/snippet} - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte index a2885f2cca..b1524c3a3c 100644 --- a/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ b/src/ifctester/webapp/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -1,7 +1,11 @@ - - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/lib/utils.js b/src/ifctester/webapp/src/lib/utils.js deleted file mode 100644 index f79e2db4c9..0000000000 --- a/src/ifctester/webapp/src/lib/utils.js +++ /dev/null @@ -1,8 +0,0 @@ -import { clsx, } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs) { - return twMerge(clsx(inputs)); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any \ No newline at end of file diff --git a/src/ifctester/webapp/src/lib/utils.ts b/src/ifctester/webapp/src/lib/utils.ts new file mode 100644 index 0000000000..be010d3ced --- /dev/null +++ b/src/ifctester/webapp/src/lib/utils.ts @@ -0,0 +1,7 @@ +import { clsx } from "clsx"; +import type { ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/ifctester/webapp/src/main.js b/src/ifctester/webapp/src/main.js deleted file mode 100644 index 14fb0c3be8..0000000000 --- a/src/ifctester/webapp/src/main.js +++ /dev/null @@ -1,9 +0,0 @@ -import { mount } from 'svelte'; -import './css/app.css'; -import App from './App.svelte'; - -const app = mount(App, { - target: document.getElementById('root'), -}); - -export default app; \ No newline at end of file diff --git a/src/ifctester/webapp/src/main.ts b/src/ifctester/webapp/src/main.ts new file mode 100644 index 0000000000..54ddb22cf9 --- /dev/null +++ b/src/ifctester/webapp/src/main.ts @@ -0,0 +1,14 @@ +import { mount } from 'svelte'; +import './css/app.css'; +import App from './App.svelte'; + +const root = document.getElementById('root'); +if (!root) { + throw new Error('Missing root element'); +} + +const app = mount(App, { + target: root, +}); + +export default app; diff --git a/src/ifctester/webapp/src/modules/api/api.svelte.js b/src/ifctester/webapp/src/modules/api/api.svelte.ts similarity index 72% rename from src/ifctester/webapp/src/modules/api/api.svelte.js rename to src/ifctester/webapp/src/modules/api/api.svelte.ts index ad2a7b8c3f..b4ebe01fdc 100644 --- a/src/ifctester/webapp/src/modules/api/api.svelte.js +++ b/src/ifctester/webapp/src/modules/api/api.svelte.ts @@ -1,8 +1,31 @@ import wasm from "$src/modules/wasm"; -import * as IDS from "$src/modules/api/ids.svelte.js"; +import * as IDS from "$src/modules/api/ids.svelte"; import hyperid from "hyperid"; +import type { AuditReport, AuditReportData } from "$src/types/report"; +import type { IdsDocument } from "$src/types/ids"; -export let Autocompletions = $state({ +type AutocompletionState = { + entityClasses: string[]; + materialCategories: string[]; + classificationSystems: Record; + dataTypes: string[]; + isLoaded: boolean; +}; + +type IfcModel = { + id: string; + fileName: string; + fileSize: number; + loadedAt: Date; +}; + +type IfcModelState = { + models: IfcModel[]; + isLoading: boolean; + audits: AuditReport[]; +}; + +export const Autocompletions: AutocompletionState = $state({ entityClasses: [], materialCategories: [], classificationSystems: {}, @@ -10,13 +33,13 @@ export let Autocompletions = $state({ isLoaded: false }); -export let IFCModels = $state({ +export const IFCModels: IfcModelState = $state({ models: [], isLoading: false, audits: [] }); -const id = hyperid(); +const id: () => string = hyperid(); // Preload autocompletions on initialization wasm.init().then(async () => { @@ -30,26 +53,30 @@ export async function preloadAutocompletions() { // Entity classes const entitySets = await Promise.all( schemas.map(schema => wasm.getAllEntityClasses(schema)) - ); - const allEntities = new Set(); - entitySets.forEach(entities => { - entities.forEach(entity => allEntities.add(entity.toUpperCase())); - }); + ) as string[][]; + const allEntities = new Set(); + for (const entities of entitySets) { + for (const entity of entities) { + allEntities.add(entity.toUpperCase()); + } + } // Data types const dataTypeSets = await Promise.all( schemas.map(schema => wasm.getAllDataTypes(schema)) - ); - const allDataTypes = new Set(); - dataTypeSets.forEach(dataTypes => { - Object.keys(dataTypes).forEach(dataType => allDataTypes.add(dataType)); - }); + ) as Record[]; + const allDataTypes = new Set(); + for (const dataTypes of dataTypeSets) { + for (const dataType of Object.keys(dataTypes as Record)) { + allDataTypes.add(dataType); + } + } // Material categories and Classification systems const [materialCategories, classificationSystems] = await Promise.all([ wasm.getMaterialCategories(), wasm.getStandardClassificationSystems() - ]); + ]) as [string[], AutocompletionState["classificationSystems"]]; // Cache autocompletions Autocompletions.entityClasses = Array.from(allEntities).sort(); @@ -64,15 +91,15 @@ export async function preloadAutocompletions() { } } -export async function getPredefinedTypes(schema, entity) { +export async function getPredefinedTypes(schema: string, entity: string) { return await wasm.getPredefinedTypes(schema, entity); } -export async function getEntityAttributes(schema, entity) { +export async function getEntityAttributes(schema: string, entity: string) { return await wasm.getEntityAttributes(schema, entity); } -export async function getApplicablePsets(schema, entity, predefinedType = '') { +export async function getApplicablePsets(schema: string, entity: string, predefinedType = '') { return await wasm.getApplicablePsets(schema, entity, predefinedType); } @@ -92,7 +119,7 @@ export function getDataTypes() { return Autocompletions.dataTypes; } -export async function loadIfc(file) { +export async function loadIfc(file: File): Promise { try { IFCModels.isLoading = true; @@ -100,10 +127,10 @@ export async function loadIfc(file) { const uint8Array = new Uint8Array(arrayBuffer); // Load IFC model - const ifcId = await wasm.loadIfc(Array.from(uint8Array)); + const ifcId = await wasm.loadIfc(Array.from(uint8Array)) as string; // Add to models list - const model = { + const model: IfcModel = { id: ifcId, fileName: file.name, fileSize: file.size, @@ -121,7 +148,7 @@ export async function loadIfc(file) { } } -export async function unloadIfc(modelId) { +export async function unloadIfc(modelId: string) { try { // Unload model await wasm.unloadIfc(modelId); @@ -136,9 +163,9 @@ export async function unloadIfc(modelId) { } } -export async function auditIfc(modelId, idsData) { +export async function auditIfc(modelId: string, idsData: string | Uint8Array | ArrayBuffer) { try { - let idsBytes; + let idsBytes: Uint8Array; if (typeof idsData === 'string') { idsBytes = new TextEncoder().encode(idsData); } else if (idsData instanceof ArrayBuffer) { @@ -148,7 +175,7 @@ export async function auditIfc(modelId, idsData) { } // Run audit - const auditResult = await wasm.auditIfc(modelId, idsBytes); + const auditResult = await wasm.auditIfc(modelId, idsBytes) as { json: AuditReportData; html: string }; console.log(`Audit completed for model ${modelId}`); return auditResult; @@ -163,13 +190,14 @@ export function getLoadedModels() { } export async function openIfc() { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.ifc'; fileInput.onchange = async (event) => { - const file = event.target.files[0]; + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; if (!file) { reject(new Error('No file selected')); return; @@ -194,15 +222,20 @@ export async function openIfc() { }); } -export function getIfcById(modelId) { +export function getIfcById(modelId: string) { return IFCModels.models.find(model => model.id === modelId); } -export function createAuditReport(modelId, document, auditData, htmlReport = null) { +export function createAuditReport( + modelId: string, + document: string, + auditData: AuditReportData, + htmlReport: string | null = null +): AuditReport | undefined { const model = getIfcById(modelId); if (!model) return; - const auditReport = { + const auditReport: AuditReport = { id: id(), modelId: modelId, modelName: model.fileName, @@ -216,19 +249,19 @@ export function createAuditReport(modelId, document, auditData, htmlReport = nul return auditReport; } -export function getAuditReportsForIfc(modelId) { +export function getAuditReportsForIfc(modelId: string) { return IFCModels.audits.filter(audit => audit.modelId === modelId); } -export function getAuditReportById(auditId) { +export function getAuditReportById(auditId: string) { return IFCModels.audits.find(audit => audit.id === auditId); } -export function clearIdsAuditReports(document) { +export function clearIdsAuditReports(document: string) { IFCModels.audits = IFCModels.audits.filter(audit => audit.document !== document); } -export async function downloadAuditReport(auditId) { +export async function downloadAuditReport(auditId: string) { const audit = getAuditReportById(auditId); if (!audit || !audit.htmlReport) { throw new Error('HTML report not available for this audit'); @@ -237,7 +270,7 @@ export async function downloadAuditReport(auditId) { // Get IDS document title for filename let filename = 'report.html'; if (audit.document && IDS.Module.documents[audit.document]) { - const doc = IDS.Module.documents[audit.document]; + const doc = IDS.Module.documents[audit.document] as IdsDocument; const title = doc.info?.title || 'untitled'; filename = `report_${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.html`; } @@ -270,9 +303,12 @@ export async function runAudit() { // Get the active IDS document XML const idsXml = await IDS.exportActiveDocument(); + if (!idsXml) { + throw new Error('Failed to export IDS document'); + } // Run audit on all loaded models - let firstAuditReport = null; + let firstAuditReport: AuditReport | undefined; for (const model of IFCModels.models) { const result = await auditIfc(model.id, idsXml); @@ -280,7 +316,10 @@ export async function runAudit() { const jsonData = result.json || null; const htmlReport = result.html || null; - const auditReport = createAuditReport(model.id, IDS.Module.activeDocument, jsonData, htmlReport); + if (!jsonData) { + continue; + } + const auditReport = createAuditReport(model.id, IDS.Module.activeDocument as string, jsonData, htmlReport); // Store the first audit report to open in viewer if (!firstAuditReport) { diff --git a/src/ifctester/webapp/src/modules/api/bonsai.svelte.js b/src/ifctester/webapp/src/modules/api/bonsai.svelte.ts similarity index 64% rename from src/ifctester/webapp/src/modules/api/bonsai.svelte.js rename to src/ifctester/webapp/src/modules/api/bonsai.svelte.ts index bcc4b8965d..5ed9b562f4 100644 --- a/src/ifctester/webapp/src/modules/api/bonsai.svelte.js +++ b/src/ifctester/webapp/src/modules/api/bonsai.svelte.ts @@ -1,12 +1,37 @@ import { io } from 'socket.io-client'; -import { IFCModels } from './api.svelte.js'; -import * as IDS from './ids.svelte.js'; -import { error, success } from '../utils/toast.svelte.js'; +import type { Socket } from 'socket.io-client'; +import { IFCModels } from './api.svelte'; +import * as IDS from './ids.svelte'; +import { error, success } from '../utils/toast.svelte'; import hyperid from 'hyperid'; -import { onMount } from 'svelte'; +import type { AuditReport, AuditReportData } from "$src/types/report"; // Bonsai connection state -export let Bonsai = $state({ +type BonsaiState = { + enabled: boolean; + port: string | null; + socket: Socket | null; + connected: boolean; + auditing: boolean; +}; + +type PendingAudit = { + resolve: (value: string | null) => void; + reject: (reason?: unknown) => void; +}; + +type AuditResultPayload = { + id?: string; + json_report?: string; + html_report?: string; +}; + +type AuditErrorPayload = { + id?: string; + error?: string; +}; + +export const Bonsai: BonsaiState = $state({ enabled: false, port: null, socket: null, @@ -14,8 +39,8 @@ export let Bonsai = $state({ auditing: false }); -const id = hyperid(); -const pendingAudits = new Map(); +const id: () => string = hyperid(); +const pendingAudits = new Map(); // Check for Bonsai server port in URL parameters const urlParams = new URLSearchParams(window.location.search); @@ -29,8 +54,11 @@ if (serverPort) { /** * Connect to Bonsai server */ -export const connect = () => new Promise((resolve, reject) => { - if (!Bonsai.port) return; +export const connect = () => new Promise((resolve, reject) => { + if (!Bonsai.port) { + resolve(); + return; + } try { Bonsai.socket = io(`ws://127.0.0.1:${Bonsai.port}/ifctester`, { @@ -49,7 +77,7 @@ export const connect = () => new Promise((resolve, reject) => { Bonsai.connected = false; }); - Bonsai.socket.on('connect_error', (err) => { + Bonsai.socket.on('connect_error', (err: Error) => { Bonsai.connected = false; error(`Failed to connect to Bonsai: ${err.message}`); reject(err); @@ -59,7 +87,8 @@ export const connect = () => new Promise((resolve, reject) => { Bonsai.socket.on('error', handleAuditError); } catch (err) { - error(`Failed to connect to Bonsai: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to connect to Bonsai: ${message}`); reject(err); } }); @@ -93,14 +122,21 @@ export const runAudit = async () => { // Convert IDS document to XML string const idsXml = await IDS.exportActiveDocument(); + if (!idsXml) { + throw new Error('Failed to export IDS document'); + } const requestId = id(); + const socket = Bonsai.socket; + if (!socket) { + throw new Error('Bonsai socket not connected'); + } - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { // Store request with resolve/reject functions pendingAudits.set(requestId, { resolve, reject }); - Bonsai.socket.emit('audit_ids', { + socket.emit('audit_ids', { id: requestId, ids: idsXml }); @@ -108,7 +144,8 @@ export const runAudit = async () => { } catch (err) { Bonsai.auditing = false; - error(`Failed to run Bonsai audit: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to run Bonsai audit: ${message}`); return null; } }; @@ -117,7 +154,7 @@ export const runAudit = async () => { * Handles audit results from Bonsai server * @param {Object} data - Audit result data */ -const handleAuditResult = (data) => { +const handleAuditResult = (data: AuditResultPayload) => { if (!data.id || !data.json_report) return; const pendingAudit = pendingAudits.get(data.id); @@ -130,13 +167,14 @@ const handleAuditResult = (data) => { const { resolve } = pendingAudit; try { - const reportData = JSON.parse(data.json_report); + const reportData = JSON.parse(data.json_report) as AuditReportData; - const auditReport = { + const auditReport: AuditReport = { id: data.id, + modelId: `bonsai:${data.id}`, date: new Date().toISOString(), modelName: 'Bonsai IFC Model', - document: IDS.Module.activeDocument, + document: IDS.Module.activeDocument ?? "", data: reportData, htmlReport: data.html_report }; @@ -152,7 +190,8 @@ const handleAuditResult = (data) => { } catch (err) { Bonsai.auditing = false; - error(`Failed to process audit result: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + error(`Failed to process audit result: ${message}`); resolve(null); } }; @@ -161,7 +200,7 @@ const handleAuditResult = (data) => { * Handles audit errors from Bonsai server * @param {Object} data - Error data */ -const handleAuditError = (data) => { +const handleAuditError = (data: AuditErrorPayload) => { if (!data.id) return; const pendingAudit = pendingAudits.get(data.id); @@ -174,7 +213,6 @@ const handleAuditError = (data) => { const { resolve } = pendingAudit; Bonsai.auditing = false; - error(`Audit failed (Bonsai): ${data.error}`); + error(`Audit failed (Bonsai): ${data.error ?? "Unknown error"}`); resolve(null); }; - diff --git a/src/ifctester/webapp/src/modules/api/ids.svelte.js b/src/ifctester/webapp/src/modules/api/ids.svelte.ts similarity index 63% rename from src/ifctester/webapp/src/modules/api/ids.svelte.js rename to src/ifctester/webapp/src/modules/api/ids.svelte.ts index 3d30948d3d..3035113654 100644 --- a/src/ifctester/webapp/src/modules/api/ids.svelte.js +++ b/src/ifctester/webapp/src/modules/api/ids.svelte.ts @@ -1,10 +1,18 @@ import wasm from "$src/modules/wasm"; -import { clearIdsAuditReports } from "./api.svelte.js"; +import { clearIdsAuditReports } from "./api.svelte"; import hyperid from "hyperid"; import {tick} from "svelte"; +import type { DocumentState, Facet, FacetValue, IdsDocument, IdsCardinality, Restriction, Specification } from "$src/types/ids"; -export let Module = $state({ - documents: [], +type ModuleState = { + documents: Record; + activeDocument: string | null; + status: "loading" | "ready" | "error"; + states: Record; +}; + +export const Module: ModuleState = $state({ + documents: {}, activeDocument: null, status: "loading", states: {} @@ -17,14 +25,15 @@ wasm.init().then(() => { Module.status = "error"; }); -const id = hyperid() +const id: () => string = hyperid(); -export function setDocumentState(docId, updates) { +export function setDocumentState(docId: string, updates: Partial) { if (!Module.states[docId]) { Module.states[docId] = { activeTab: 'info', viewMode: 'editor', - activeSpecification: null + activeSpecification: null, + auditReport: null }; } Object.assign(Module.states[docId], updates); @@ -32,7 +41,7 @@ export function setDocumentState(docId, updates) { export async function createDocument() { const docId = id(); - const doc = await wasm.createIDS(); + const doc = await wasm.createIDS() as IdsDocument; Module.documents[docId] = doc; @@ -43,14 +52,14 @@ export async function createDocument() { Module.activeDocument = docId; } -export async function deleteDocument(id) { +export async function deleteDocument(id: string) { // Clear any audit reports generated using this IDS document clearIdsAuditReports(id); delete Module.documents[id]; delete Module.states[id]; - if (Module.activeDocument == id) { + if (Module.activeDocument === id) { // If there are other documents, set the first one as active if (Object.keys(Module.documents).length > 0) { Module.activeDocument = Object.keys(Module.documents)[0]; @@ -62,19 +71,19 @@ export async function deleteDocument(id) { // Normalize (remove xs: prefix) from JSON dict returned from Python // We need this because the backend exports with xs: prefix, yet expects a dict without prefixes. -function normalizeIdsDict(obj) { +function normalizeIdsDict(obj: unknown): unknown { if (typeof obj !== 'object' || obj === null) return obj; if (Array.isArray(obj)) { return obj.map(normalizeIdsDict); } - const result = {}; + const result: Record = {}; for (const [key, value] of Object.entries(obj)) { if (key === 'xs:restriction' && Array.isArray(value) && value.length > 0) { // Convert xs:restriction array to restriction object - const restriction = value[0]; - const newRestriction = {}; + const restriction = value[0] as Record; + const newRestriction: Record = {}; for (const [restrictionKey, restrictionValue] of Object.entries(restriction)) { if (restrictionKey.startsWith('xs:')) { @@ -86,7 +95,7 @@ function normalizeIdsDict(obj) { } } - result['restriction'] = newRestriction; + result.restriction = newRestriction; } else { result[key] = normalizeIdsDict(value); } @@ -96,13 +105,16 @@ function normalizeIdsDict(obj) { } export async function openDocument() { - return new Promise((resolve, reject) => { - const fileInput = document.createElement('input'); + return new Promise((resolve, reject) => { + const fileInput = document.createElement('input') as HTMLInputElement & { + oncancel?: ((this: HTMLInputElement, ev: Event) => void) | null; + }; fileInput.type = 'file'; fileInput.accept = '.ids,.xml'; fileInput.onchange = async (event) => { - const file = event.target.files[0]; + const target = event.target as HTMLInputElement | null; + const file = target?.files?.[0]; if (!file) { reject(new Error('No file selected')); return; @@ -112,8 +124,8 @@ export async function openDocument() { const reader = new FileReader(); reader.onload = async (e) => { try { - const fileContent = e.target.result; - const doc = normalizeIdsDict(await wasm.openIDS(fileContent, false)); + const fileContent = (e.target as FileReader).result; + const doc = normalizeIdsDict(await wasm.openIDS(String(fileContent), false)) as IdsDocument; const docId = id(); // Add document to list and set as active @@ -145,16 +157,16 @@ export async function openDocument() { }); } -export async function exportActiveDocument() { +export async function exportActiveDocument(): Promise { if (!Module.activeDocument) return null; const doc = $state.snapshot(Module.documents[Module.activeDocument]); - const xmlString = await wasm.exportIDS(doc); + const xmlString = await wasm.exportIDS(doc as Record) as string; return xmlString; } -export async function exportDocument(docId) { +export async function exportDocument(docId: string) { const doc = $state.snapshot(Module.documents[docId]); // Validate @@ -162,37 +174,38 @@ export async function exportDocument(docId) { throw new Error("Please create at least one specification before exporting the document."); } - const xmlString = await wasm.exportIDS(doc); + const xmlString = await wasm.exportIDS(doc as Record) as string; // Create and download file const blob = new Blob([xmlString], { type: 'application/xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${Module.documents[docId].info.title.replace(/[^a-zA-Z0-9]/g, '_')}.ids`; + const title = Module.documents[docId].info.title || "untitled"; + a.download = `${title.replace(/[^a-zA-Z0-9]/g, '_')}.ids`; a.click(); URL.revokeObjectURL(url); } -export async function createSpecification(docId) { - const spec = await wasm.createSpecification(); +export async function createSpecification(docId: string) { + const spec = await wasm.createSpecification() as Specification; // Add specification to document Module.documents[docId].specifications.specification.push(spec); // Set as active specification - if (Module.activeDocument == docId) { + if (Module.activeDocument === docId) { const state = Module.states[docId]; state.activeSpecification = Module.documents[docId].specifications.specification.length - 1; } } -export async function deleteSpecification(docId, specId) { +export async function deleteSpecification(docId: string, specId: number) { Module.documents[docId].specifications.specification.splice(specId, 1); - if (Module.activeDocument == docId) { + if (Module.activeDocument === docId) { const state = Module.states[docId]; - if (state.activeSpecification == specId) { + if (state.activeSpecification === specId) { // We need to wait for the next tick here because of Svelte's internal shenanigans await tick(); setDocumentState(docId, { activeSpecification: null }); @@ -209,124 +222,148 @@ export async function deleteSpecification(docId, specId) { * clause: "applicability", "requirements" * facet: "entity", "attribute", "classification", "partOf", "property", "material" */ -export async function createFacet(docId, specId, clause, facet) { - let facetObj; - if (facet == "entity") { - facetObj = await wasm.createEntityFacet(clause, {}); - } else if (facet == "attribute") { - facetObj = await wasm.createAttributeFacet(clause, {}); - } else if (facet == "classification") { - facetObj = await wasm.createClassificationFacet(clause, {}); - } else if (facet == "partOf") { - facetObj = await wasm.createPartOfFacet(clause, {}); - } else if (facet == "property") { - facetObj = await wasm.createPropertyFacet(clause, {}); - } else if (facet == "material") { - facetObj = await wasm.createMaterialFacet(clause, {}); +export async function createFacet( + docId: string, + specId: number, + clause: "applicability" | "requirements", + facet: "entity" | "attribute" | "classification" | "partOf" | "property" | "material" +) { + let facetObj: Facet | undefined; + if (facet === "entity") { + facetObj = await wasm.createEntityFacet(clause, {}) as Facet; + } else if (facet === "attribute") { + facetObj = await wasm.createAttributeFacet(clause, {}) as Facet; + } else if (facet === "classification") { + facetObj = await wasm.createClassificationFacet(clause, {}) as Facet; + } else if (facet === "partOf") { + facetObj = await wasm.createPartOfFacet(clause, {}) as Facet; + } else if (facet === "property") { + facetObj = await wasm.createPropertyFacet(clause, {}) as Facet; + } else if (facet === "material") { + facetObj = await wasm.createMaterialFacet(clause, {}) as Facet; } - if (!(facet in Module.documents[docId].specifications.specification[specId][clause])) { - Module.documents[docId].specifications.specification[specId][clause][facet] = []; + if (!facetObj) return; + + const spec = Module.documents[docId].specifications.specification[specId]; + const clauseKey = clause as "applicability" | "requirements"; + if (!spec[clauseKey]) spec[clauseKey] = {}; + if (!(facet in (spec[clauseKey] as Record))) { + (spec[clauseKey] as Record)[facet] = []; } - Module.documents[docId].specifications.specification[specId][clause][facet].push(facetObj); + ((spec[clauseKey] as Record)[facet] as Facet[]).push(facetObj); } -export async function deleteFacet(docId, specId, clause, facet, facetId) { - delete Module.documents[docId].specifications.specification[specId][clause][facet][facetId]; +export async function deleteFacet( + docId: string, + specId: number, + clause: "applicability" | "requirements", + facet: "entity" | "attribute" | "classification" | "partOf" | "property" | "material", + facetId: number +) { + const spec = Module.documents[docId].specifications.specification[specId]; + const list = (spec[clause] as Record | undefined)?.[facet] as Facet[] | undefined; + if (!list) return; + list.splice(facetId, 1); } -export function getSpecUsage(spec) { +export function getSpecUsage(spec?: Specification | null): IdsCardinality { if (!spec?.applicability) return 'required'; - const minOccurs = spec.applicability["@minOccurs"]; - const maxOccurs = spec.applicability["@maxOccurs"]; - - if (minOccurs === 1 && maxOccurs === "unbounded") return 'required'; - if (minOccurs === 0 && maxOccurs === "unbounded") return 'optional'; - if (minOccurs === 0 && maxOccurs === 0) return 'prohibited'; + const minOccurs = spec.applicability["@minOccurs"] as number | undefined; + const maxOccurs = spec.applicability["@maxOccurs"] as number | "unbounded" | undefined; + + if (minOccurs !== 0) return 'required'; + if (minOccurs === 0 && maxOccurs !== 0) return 'optional'; + if (maxOccurs === 0) return 'prohibited'; return 'required'; }; // Converts facet to human-readable description -export function stringifyFacet(clauseType, facet, facetType, spec) { +export function stringifyFacet( + clauseType: "applicability" | "requirements", + facet: Facet, + facetType: string, + spec?: Specification | null +) { if (!facet) return ""; const usage = getSpecUsage(spec); - const descriptions = []; + const descriptions: string[] = []; // Entity facet if (facetType === "entity") { if (clauseType === "applicability") { - descriptions.push(`All data where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`All data where IFC class ${stringifyValue(facet.name as FacetValue)}`); } else { - descriptions.push(`Shall be data where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`Shall be data where IFC class ${stringifyValue(facet.name as FacetValue)}`); } if (facet.predefinedType) { - descriptions.push(`and type ${stringifyValue(facet.predefinedType)}`); + descriptions.push(`and type ${stringifyValue(facet.predefinedType as FacetValue)}`); } } // Attribute facet else if (facetType === "attribute") { if (clauseType === "applicability") { - descriptions.push(`All data where attribute ${stringifyValue(facet.name)}`); + descriptions.push(`All data where attribute ${stringifyValue(facet.name as FacetValue)}`); } else { - descriptions.push(`Shall be data where attribute ${stringifyValue(facet.name)}`); + descriptions.push(`Shall be data where attribute ${stringifyValue(facet.name as FacetValue)}`); } - descriptions.push(`and value ${stringifyValue(facet.value)}`); + descriptions.push(`and value ${stringifyValue(facet.value as FacetValue)}`); } // Property facet else if (facetType === "property") { if (clauseType === "applicability") { - descriptions.push(`Elements where property ${stringifyValue(facet.baseName)}`); + descriptions.push(`Elements where property ${stringifyValue(facet.baseName as FacetValue)}`); } else { - descriptions.push(`Shall be elements where property ${stringifyValue(facet.baseName)}`); + descriptions.push(`Shall be elements where property ${stringifyValue(facet.baseName as FacetValue)}`); } if (facet.value) { - descriptions.push(`and value ${stringifyValue(facet.value)}`); + descriptions.push(`and value ${stringifyValue(facet.value as FacetValue)}`); } - descriptions.push(`and dataset ${stringifyValue(facet.propertySet)}`); + descriptions.push(`and dataset ${stringifyValue(facet.propertySet as FacetValue)}`); } // Classification facet else if (facetType === "classification") { if (clauseType === "applicability") { - descriptions.push(`All data where classification system ${stringifyValue(facet.system)}`); + descriptions.push(`All data where classification system ${stringifyValue(facet.system as FacetValue)}`); } else { - descriptions.push(`Shall be data where classification system ${stringifyValue(facet.system)}`); + descriptions.push(`Shall be data where classification system ${stringifyValue(facet.system as FacetValue)}`); } if (facet.value) { - descriptions.push(`and classification ${stringifyValue(facet.value)}`); + descriptions.push(`and classification ${stringifyValue(facet.value as FacetValue)}`); } } // Material facet else if (facetType === "material") { if (clauseType === "applicability") { - descriptions.push(`All data where material ${stringifyValue(facet.value)}`); + descriptions.push(`All data where material ${stringifyValue(facet.value as FacetValue)}`); } else { - descriptions.push(`Shall be data where material ${stringifyValue(facet.value)}`); + descriptions.push(`Shall be data where material ${stringifyValue(facet.value as FacetValue)}`); } } // PartOf facet else if (facetType === "partOf") { if (clauseType === "applicability") { - descriptions.push(`An element with an **${facet['@relation']}** relationship`); + descriptions.push(`An element with an **${String(facet['@relation'] ?? "")}** relationship`); if (facet.name) { - descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name as FacetValue)}`); } } else { - descriptions.push(`An element shall have an **${facet['@relation']}** relationship`); + descriptions.push(`An element shall have an **${String(facet['@relation'] ?? "")}** relationship`); if (facet.name) { - descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name)}`); + descriptions.push(`with an entity where IFC class ${stringifyValue(facet.name as FacetValue)}`); } if (facet.predefinedType) { - descriptions.push(`and predefined type ${stringifyValue(facet.predefinedType)}`); + descriptions.push(`and predefined type ${stringifyValue(facet.predefinedType as FacetValue)}`); } } } @@ -336,20 +373,22 @@ export function stringifyFacet(clauseType, facet, facetType, spec) { // Post-process for prohibited and optional requirements let isProhibited = false; - if (usage == "prohibited") isProhibited = !isProhibited; - if (clauseType == "requirements" && "@cardinality" in facet && facet["@cardinality"] == "prohibited") isProhibited = !isProhibited; + if (usage === "prohibited") isProhibited = !isProhibited; + if (clauseType === "requirements" && "@cardinality" in facet && facet["@cardinality"] === "prohibited") { + isProhibited = !isProhibited; + } if (isProhibited) combined = combined.replace("Shall", "Shall not").replace("shall", "shall not"); - if (clauseType == "requirements" && "@cardinality" in facet && facet["@cardinality"] == "optional") + if (clauseType === "requirements" && "@cardinality" in facet && facet["@cardinality"] === "optional") combined = combined.replace("Shall", "May").replace("shall", "may"); return renderFacetString(combined); } // Converts value objects to human-readable strings -function stringifyValue(value) { +function stringifyValue(value?: FacetValue) { if (!value) return "is provided"; if (value.simpleValue) return `is **${value.simpleValue}**`; if (value.restriction) return stringifyRestriction(value.restriction); @@ -357,7 +396,7 @@ function stringifyValue(value) { } // Converts restriction objects to human-readable strings -function stringifyRestriction(restriction) { +function stringifyRestriction(restriction: Restriction) { if (!restriction) return ""; // Handle enumeration @@ -394,7 +433,7 @@ function stringifyRestriction(restriction) { if (restriction.maxExclusive && restriction.maxExclusive.length > 0) { parts.push(`**< ${restriction.maxExclusive[0]['@value'] || ''}**`); } - return parts.length > 0 ? "is in range " + parts.join(", ") : "has range restriction"; + return parts.length > 0 ? `is in range ${parts.join(", ")}` : "has range restriction"; } // Handle length range restrictions @@ -406,18 +445,18 @@ function stringifyRestriction(restriction) { if (restriction.maxLength && restriction.maxLength.length > 0) { parts.push(`**max length ${restriction.maxLength[0]['@value'] || ''}**`); } - return parts.length > 0 ? "has " + parts.join(", ") : "has length range restriction"; + return parts.length > 0 ? `has ${parts.join(", ")}` : "has length range restriction"; } return "has complex restriction"; } -function renderFacetString(text) { +function renderFacetString(text: string): string { // Convert **text** to text - text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + const withStrong = text.replace(/\*\*([^*]+)\*\*/g, '$1'); // Convert `text` to text - text = text.replace(/`([^`]+)`/g, '$1'); + const withCode = withStrong.replace(/`([^`]+)`/g, '$1'); - return text; -} \ No newline at end of file + return withCode; +} diff --git a/src/ifctester/webapp/src/modules/utils/toast.svelte.js b/src/ifctester/webapp/src/modules/utils/toast.svelte.ts similarity index 73% rename from src/ifctester/webapp/src/modules/utils/toast.svelte.js rename to src/ifctester/webapp/src/modules/utils/toast.svelte.ts index eaf9b373f0..335be48645 100644 --- a/src/ifctester/webapp/src/modules/utils/toast.svelte.js +++ b/src/ifctester/webapp/src/modules/utils/toast.svelte.ts @@ -4,7 +4,7 @@ import { toast } from "svelte-sonner"; * Show an error toast notification * @param {string} message - The error message to display */ -export function error(message) { +export function error(message: string): void { toast.error(message); } @@ -12,7 +12,7 @@ export function error(message) { * Show a success toast notification * @param {string} message - The success message to display */ -export function success(message) { +export function success(message: string): void { toast.success(message); } @@ -20,7 +20,7 @@ export function success(message) { * Show an info toast notification * @param {string} message - The info message to display */ -export function info(message) { +export function info(message: string): void { toast.info(message); } @@ -28,7 +28,7 @@ export function info(message) { * Show a warning toast notification * @param {string} message - The warning message to display */ -export function warning(message) { +export function warning(message: string): void { toast.warning(message); } @@ -37,7 +37,7 @@ export function warning(message) { * @param {string} message - The loading message to display * @returns {string} - Toast ID for dismissing later */ -export function loading(message) { +export function loading(message: string): string | number { return toast.loading(message); } @@ -45,7 +45,7 @@ export function loading(message) { * Dismiss a specific toast * @param {string} toastId - The toast ID to dismiss */ -export function dismiss(toastId) { +export function dismiss(toastId: string | number): void { toast.dismiss(toastId); } @@ -57,10 +57,16 @@ export function dismiss(toastId) { * @param {string} messages.success - Success message * @param {string} messages.error - Error message */ -export function promise(promiseToTrack, messages) { +type PromiseToastMessages = { + loading: string; + success: string; + error: string; +}; + +export function promise(promiseToTrack: Promise, messages: PromiseToastMessages) { return toast.promise(promiseToTrack, { loading: messages.loading, success: messages.success, error: messages.error, }); -} \ No newline at end of file +} diff --git a/src/ifctester/webapp/src/modules/wasm/index.js b/src/ifctester/webapp/src/modules/wasm/index.ts similarity index 69% rename from src/ifctester/webapp/src/modules/wasm/index.js rename to src/ifctester/webapp/src/modules/wasm/index.ts index cecb655f73..d7c4f1e40a 100644 --- a/src/ifctester/webapp/src/modules/wasm/index.js +++ b/src/ifctester/webapp/src/modules/wasm/index.ts @@ -5,6 +5,7 @@ import hyperid from "hyperid"; import EventEmitter from "eventemitter3"; +import type { WorkerResponse } from "$src/types/wasm"; // Message types export const MessageType = { @@ -25,52 +26,57 @@ export const MessageType = { // WASM module disposed DISPOSED: 'disposed' +} as const; + +type PendingMessage = { + resolve: (value?: unknown) => void; + reject: (reason?: unknown) => void; }; +type WasmReadyState = boolean | Promise; + class WASMModule extends EventEmitter { id = hyperid(); - ready = false; - worker = null; - pendingMessages = new Map(); + ready: WasmReadyState = false; + worker: Worker | null = null; + pendingMessages = new Map(); async init() { if (this.ready === true) return; - else if (this.ready instanceof Promise) return this.ready; + if (this.ready instanceof Promise) return this.ready; - this.worker = new Worker(new URL('./worker/worker.js', import.meta.url), {type: 'module'}); + this.worker = new Worker(new URL('./worker/worker.ts', import.meta.url), {type: 'module'}); - this.worker.onmessage = (event) => { + this.worker.onmessage = (event: MessageEvent) => { this._handleWorkerMessage(event.data); }; - this.worker.onerror = (error) => { + this.worker.onerror = (error: ErrorEvent) => { console.error('[WASM] Web worker error:', error); this._rejectPendingMessages(error); }; - this.ready = new Promise(async (resolve, reject) => { - try { - await this._sendMessage(MessageType.INIT); - resolve(true); - } catch (error) { + this.ready = this._sendMessage(MessageType.INIT) + .then(() => true) + .catch((error) => { console.error('[WASM] Failed to initialize:', error); this.ready = false; - reject(error); - } - }); + throw error; + }); return this.ready; } - async _sendMessage(type, payload = {}) { - if (!this.worker) throw new Error('Worker not initialized'); + async _sendMessage(type: string, payload: Record = {}): Promise { + const worker = this.worker; + if (!worker) throw new Error('Worker not initialized'); const id = this.id(); return new Promise((resolve, reject) => { this.pendingMessages.set(id, { resolve, reject }); - this.worker.postMessage({ + worker.postMessage({ type, payload, id @@ -78,7 +84,7 @@ class WASMModule extends EventEmitter { }); } - _handleWorkerMessage({ type, payload, id }) { + _handleWorkerMessage({ type, payload, id }: WorkerResponse) { const pendingMessage = this.pendingMessages.get(id); if (!pendingMessage) { @@ -97,23 +103,28 @@ class WASMModule extends EventEmitter { case MessageType.API_RESPONSE: resolve(payload); break; - case MessageType.ERROR: - reject(new Error(payload.message)); + case MessageType.ERROR: { + const message = + payload && typeof payload === "object" && "message" in payload + ? String(payload.message) + : "Unknown worker error"; + reject(new Error(message)); break; + } default: console.warn('[WASM] Unknown message type:', type); reject(new Error(`Unknown message type: ${type}`)); } } - _rejectPendingMessages(error) { + _rejectPendingMessages(error: unknown) { for (const { reject } of this.pendingMessages.values()) { reject(error); } this.pendingMessages.clear(); } - async _apiCall(method, ...args) { + async _apiCall(method: string, ...args: unknown[]) { if (!this.ready) await this.init(); const result = await this._sendMessage(MessageType.API_CALL, { method, args }); @@ -123,35 +134,35 @@ class WASMModule extends EventEmitter { /** * Get all entity classes in a given IFC schema */ - async getAllEntityClasses(schema) { + async getAllEntityClasses(schema: string) { return this._apiCall('getAllEntityClasses', schema); } /** * Get all data types in a given IFC schema */ - async getAllDataTypes(schema) { + async getAllDataTypes(schema: string) { return this._apiCall('getAllDataTypes', schema); } /** * Get predefined types for a given IFC entity */ - async getPredefinedTypes(schema, entity) { + async getPredefinedTypes(schema: string, entity: string) { return this._apiCall('getPredefinedTypes', schema, entity); } /** * Get all attributes for a given IFC entity */ - async getEntityAttributes(schema, entity) { + async getEntityAttributes(schema: string, entity: string) { return this._apiCall('getEntityAttributes', schema, entity); } /** * Get applicable property sets for a given IFC entity */ - async getApplicablePsets(schema, entity, predefinedType = '') { + async getApplicablePsets(schema: string, entity: string, predefinedType = '') { return this._apiCall('getApplicablePsets', schema, entity, predefinedType); } @@ -172,21 +183,21 @@ class WASMModule extends EventEmitter { /** * Load an IFC file. Returns a unique ID for the loaded file. */ - async loadIfc(ifcData) { + async loadIfc(ifcData: number[] | Uint8Array | ArrayBuffer) { return this._apiCall('loadIfc', ifcData); } /** * Unload an IFC file */ - async unloadIfc(ifcId) { + async unloadIfc(ifcId: string) { return this._apiCall('unloadIfc', ifcId); } /** * Audit a loaded IFC file against IDS specifications */ - async auditIfc(ifcId, idsData) { + async auditIfc(ifcId: string, idsData: ArrayBuffer | Uint8Array | number[]) { const idsBytes = idsData instanceof ArrayBuffer ? new Uint8Array(idsData) : idsData; return this._apiCall('auditIfc', ifcId, Array.from(idsBytes)); @@ -204,70 +215,70 @@ class WASMModule extends EventEmitter { /** * Open an existing IDS from XML string */ - async openIDS(idsXml, validate = false) { + async openIDS(idsXml: string, validate = false) { return this._apiCall('openIDS', idsXml, validate); } /** * Create a specification */ - async createSpecification(options = {}) { + async createSpecification(options: Record = {}) { return this._apiCall('createSpecification', options); } /** * Create an entity facet */ - async createEntityFacet(clause, options = {}) { + async createEntityFacet(clause: string, options: Record = {}) { return this._apiCall('createEntityFacet', clause, options); } /** * Create an attribute facet */ - async createAttributeFacet(clause, options = {}) { + async createAttributeFacet(clause: string, options: Record = {}) { return this._apiCall('createAttributeFacet', clause, options); } /** * Create a property facet */ - async createPropertyFacet(clause, options = {}) { + async createPropertyFacet(clause: string, options: Record = {}) { return this._apiCall('createPropertyFacet', clause, options); } /** * Create a material facet */ - async createMaterialFacet(clause, options = {}) { + async createMaterialFacet(clause: string, options: Record = {}) { return this._apiCall('createMaterialFacet', clause, options); } /** * Create a classification facet */ - async createClassificationFacet(clause, options = {}) { + async createClassificationFacet(clause: string, options: Record = {}) { return this._apiCall('createClassificationFacet', clause, options); } /** * Create a part-of facet */ - async createPartOfFacet(clause, options = {}) { + async createPartOfFacet(clause: string, options: Record = {}) { return this._apiCall('createPartOfFacet', clause, options); } /** * Validate an IDS object */ - async validateIDS(idsObj) { + async validateIDS(idsObj: Record) { return await this._apiCall('validateIDS', idsObj); } /** * Export IDS instance to XML string */ - async exportIDS(idsObj) { + async exportIDS(idsObj: Record) { return this._apiCall('exportIDS', idsObj); } @@ -319,4 +330,4 @@ export const { dispose } = wasm; -export default wasm; \ No newline at end of file +export default wasm; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/api.js b/src/ifctester/webapp/src/modules/wasm/worker/api.ts similarity index 80% rename from src/ifctester/webapp/src/modules/wasm/worker/api.js rename to src/ifctester/webapp/src/modules/wasm/worker/api.ts index d08c17efe2..283971bc27 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/api.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/api.ts @@ -1,12 +1,13 @@ -import config from '../../../config.json'; import hyperid from 'hyperid'; +import config from '../../../config.json'; +import type { AuditReportData } from "$src/types/report"; -let pyodide = null; -let id = hyperid(); +let pyodide: any = null; +const id = hyperid(); -let LoadedIFC = new Map(); +const LoadedIFC = new Map(); -export async function init(pdide) { +export async function init(pdide: any) { pyodide = pdide; // Load Python API bindings @@ -18,7 +19,7 @@ export async function init(pdide) { `); } -export async function getPredefinedTypes(schema, entity) { +export async function getPredefinedTypes(schema: string, entity: string) { const result = await pyodide.runPythonAsync(` from api import get_predefined_types_for_entity predef_types = get_predefined_types_for_entity("${schema}", "${entity}") @@ -27,7 +28,7 @@ export async function getPredefinedTypes(schema, entity) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getAllEntityClasses(schema) { +export async function getAllEntityClasses(schema: string) { const result = await pyodide.runPythonAsync(` from api import get_all_entity_classes entities = get_all_entity_classes("${schema}") @@ -36,7 +37,7 @@ export async function getAllEntityClasses(schema) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getAllDataTypes(schema) { +export async function getAllDataTypes(schema: string) { const result = await pyodide.runPythonAsync(` from api import get_all_data_types data_types = get_all_data_types("${schema}") @@ -45,7 +46,7 @@ export async function getAllDataTypes(schema) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getEntityAttributes(schema, entity) { +export async function getEntityAttributes(schema: string, entity: string) { const result = await pyodide.runPythonAsync(` from api import get_entity_attributes attrs = get_entity_attributes("${schema}", "${entity}") @@ -54,7 +55,7 @@ export async function getEntityAttributes(schema, entity) { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function getApplicablePsets(schema, entity, predefinedType = '') { +export async function getApplicablePsets(schema: string, entity: string, predefinedType = '') { const result = await pyodide.runPythonAsync(` from api import get_applicable_psets psets = get_applicable_psets("${schema}", "${entity}", "${predefinedType}") @@ -81,7 +82,7 @@ export async function getStandardClassificationSystems() { return result.toJs({ dict_converter: Object.fromEntries }); } -export async function loadIfc(ifcData) { +export async function loadIfc(ifcData: number[] | Uint8Array | ArrayBuffer) { const ifc_id = id(); const path = `/tmp/${encodeURIComponent(ifc_id)}.ifc`; @@ -97,14 +98,14 @@ export async function loadIfc(ifcData) { return ifc_id; } -export async function unloadIfc(ifcId) { +export async function unloadIfc(ifcId: string) { const path = `/tmp/${encodeURIComponent(ifcId)}.ifc`; pyodide.FS.unlink(path); LoadedIFC.delete(ifcId); } -export async function auditIfc(ifcId, idsData) { +export async function auditIfc(ifcId: string, idsData: number[] | Uint8Array | ArrayBuffer) { const reporter = pyodide.pyimport("ifctester.reporter"); const api = pyodide.pyimport("api"); @@ -116,16 +117,16 @@ export async function auditIfc(ifcId, idsData) { specs.validate(ifc); // Create report in both HTML and JSON formats - let jsonReporter = reporter.Json(specs); + const jsonReporter = reporter.Json(specs); jsonReporter.report(); const jsonReport = jsonReporter.to_string(); - let htmlReporter = reporter.Html(specs); + const htmlReporter = reporter.Html(specs); htmlReporter.report(); const htmlReport = htmlReporter.to_string(); return { - json: JSON.parse(jsonReport), + json: JSON.parse(jsonReport) as AuditReportData, html: htmlReport }; } @@ -142,4 +143,4 @@ export const API = { "loadIfc": loadIfc, "unloadIfc": unloadIfc, "auditIfc": auditIfc -}; \ No newline at end of file +}; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/ids.js b/src/ifctester/webapp/src/modules/wasm/worker/ids.ts similarity index 57% rename from src/ifctester/webapp/src/modules/wasm/worker/ids.js rename to src/ifctester/webapp/src/modules/wasm/worker/ids.ts index 8afc023cec..ca34b21d75 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/ids.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/ids.ts @@ -2,13 +2,19 @@ * IDS module */ -let pyodide = null; +let pyodide: any = null; // IDS Python classes -let Ids, Specification; -let Entity, Attribute, Property, Material, Classification, PartOf; +let Ids: any; +let Specification: any; +let Entity: any; +let Attribute: any; +let Property: any; +let Material: any; +let Classification: any; +let PartOf: any; -export async function init(pdide) { +export async function init(pdide: any) { pyodide = pdide; await pyodide.loadPackagesFromImports(` @@ -29,24 +35,24 @@ export async function init(pdide) { PartOf = pyodide.pyimport("ifctester.facet").PartOf; } -function _idsToInstance(idsObj) { +function _idsToInstance(idsObj: Record) { const ids_raw = Ids(); return ids_raw.parse(pyodide.toPy(idsObj)) } -export function createIDS() { +export function createIDS(): Record { const ids_raw = Ids() return ids_raw.asdict().toJs({dict_converter: Object.fromEntries}); } -export function openIDS(ids_xml, validate = false) { +export function openIDS(ids_xml: string, validate = false): Record { const ids_from_xml_string = pyodide.pyimport("api").ids_from_xml_string; const ids_raw = ids_from_xml_string(ids_xml, validate); return ids_raw.asdict().toJs({dict_converter: Object.fromEntries}); } -export function validateIDS(idsObj) { +export function validateIDS(idsObj: Record): boolean { const ids_raw = _idsToInstance(idsObj) const tempFilename = `temp_${Date.now()}.xml`; const isValid = ids_raw.to_xml(tempFilename); // to_xml validates the XML as well, as far as I understand @@ -60,12 +66,26 @@ export function validateIDS(idsObj) { return isValid; } -export function exportIDS(idsObj) { +export function exportIDS(idsObj: Record): string { const ids_raw = _idsToInstance(idsObj) return ids_raw.to_string(); } -export function createSpecification({name = "Unnamed", ifcVersion = ["IFC2X3", "IFC4"], identifier = null, description = null, instructions = null, usage = "required"}) { +export function createSpecification({ + name = "Unnamed", + ifcVersion = ["IFC2X3", "IFC4"], + identifier = null, + description = null, + instructions = null, + usage = "required" +}: { + name?: string; + ifcVersion?: string[]; + identifier?: string | null; + description?: string | null; + instructions?: string | null; + usage?: string; +} = {}): Record { const spec = Specification.callKwargs({ name: name, ifcVersion: ifcVersion, @@ -79,7 +99,14 @@ export function createSpecification({name = "Unnamed", ifcVersion = ["IFC2X3", " } // @instructions -export function createEntityFacet(clause, {name = "IFCWALL", predefinedType = null, instructions = null}) { +export function createEntityFacet( + clause: string, + {name = "IFCWALL", predefinedType = null, instructions = null}: { + name?: string; + predefinedType?: string | null; + instructions?: string | null; + } = {} +): Record { const entity = Entity.callKwargs({ name: name, predefinedType: predefinedType, @@ -89,7 +116,15 @@ export function createEntityFacet(clause, {name = "IFCWALL", predefinedType = nu } // @cardinality, @instructions -export function createAttributeFacet(clause, {name = "Name", value = null, cardinality = "required", instructions = null}) { +export function createAttributeFacet( + clause: string, + {name = "Name", value = null, cardinality = "required", instructions = null}: { + name?: string; + value?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const attribute = Attribute.callKwargs({ name: name, value: value, @@ -100,7 +135,16 @@ export function createAttributeFacet(clause, {name = "Name", value = null, cardi } // @uri, @cardinality, @instructions -export function createClassificationFacet(clause, {value = null, system = null, uri = null, cardinality = "required", instructions = null}) { +export function createClassificationFacet( + clause: string, + {value = null, system = null, uri = null, cardinality = "required", instructions = null}: { + value?: string | null; + system?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const classification = Classification.callKwargs({ value: value, system: system, @@ -112,7 +156,16 @@ export function createClassificationFacet(clause, {value = null, system = null, } // @relation, @cardinality, @instructions -export function createPartOfFacet(clause, {name = "IFCWALL", predefinedType = null, relation = null, cardinality = "required", instructions = null}) { +export function createPartOfFacet( + clause: string, + {name = "IFCWALL", predefinedType = null, relation = null, cardinality = "required", instructions = null}: { + name?: string; + predefinedType?: string | null; + relation?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const part_of = PartOf.callKwargs({ name: name, predefinedType: predefinedType, @@ -124,7 +177,26 @@ export function createPartOfFacet(clause, {name = "IFCWALL", predefinedType = nu } // @dataType, @uri, @cardinality, @instructions -export function createPropertyFacet(clause, {propertySet = "Property_Set", baseName = "propertyName", value = null, dataType = null, uri = null, cardinality = "required", instructions = null}) { +export function createPropertyFacet( + clause: string, + { + propertySet = "Property_Set", + baseName = "propertyName", + value = null, + dataType = null, + uri = null, + cardinality = "required", + instructions = null + }: { + propertySet?: string; + baseName?: string; + value?: string | null; + dataType?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const property = Property.callKwargs({ propertySet: propertySet, baseName: baseName, @@ -138,7 +210,15 @@ export function createPropertyFacet(clause, {propertySet = "Property_Set", baseN } // @uri, @cardinality, @instructions -export function createMaterialFacet(clause, {value = null, uri = null, cardinality = "required", instructions = null}) { +export function createMaterialFacet( + clause: string, + {value = null, uri = null, cardinality = "required", instructions = null}: { + value?: string | null; + uri?: string | null; + cardinality?: string; + instructions?: string | null; + } = {} +): Record { const material = Material.callKwargs({ value: value, uri: uri, @@ -149,7 +229,7 @@ export function createMaterialFacet(clause, {value = null, uri = null, cardinali } // Helper function to convert date to ISO format string -export function formatDate(date) { +export function formatDate(date?: string | number | Date | null): string | null { if (!date) return null; const d = new Date(date); return d.toISOString().split('T')[0]; @@ -168,4 +248,4 @@ export const API = { "createPartOfFacet": createPartOfFacet, "createPropertyFacet": createPropertyFacet, "createMaterialFacet": createMaterialFacet, -}; \ No newline at end of file +}; diff --git a/src/ifctester/webapp/src/modules/wasm/worker/worker.js b/src/ifctester/webapp/src/modules/wasm/worker/worker.ts similarity index 61% rename from src/ifctester/webapp/src/modules/wasm/worker/worker.js rename to src/ifctester/webapp/src/modules/wasm/worker/worker.ts index 6bf47e46fe..fe4b188644 100644 --- a/src/ifctester/webapp/src/modules/wasm/worker/worker.js +++ b/src/ifctester/webapp/src/modules/wasm/worker/worker.ts @@ -4,13 +4,14 @@ import { MessageType } from '../index'; import config from '../../../config.json'; -import * as IDS from './ids.js'; +import * as IDS from './ids'; import * as API from './api'; +import type { ApiCallPayload, WorkerRequest } from "$src/types/wasm"; -let pyodide = null; +let pyodide: any = null; let ready = false; -self.addEventListener('message', async (event) => { +self.addEventListener('message', async (event: MessageEvent) => { console.log("[worker] Received message:", event.data); const { type, payload, id } = event.data; @@ -25,27 +26,33 @@ self.addEventListener('message', async (event) => { }); break; - case MessageType.API_CALL: + case MessageType.API_CALL: { if (!ready) { throw new Error('[worker] Pyodide not initialized'); } - const result = await handleApiCall(payload); + if (!payload) { + throw new Error('[worker] Missing payload for API call'); + } + const result = await handleApiCall(payload as ApiCallPayload); self.postMessage({ type: MessageType.API_RESPONSE, payload: result, id }); break; + } default: throw new Error(`[worker] Unknown message type: ${type}`); } } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; self.postMessage({ type: MessageType.ERROR, payload: { - message: error.message, - stack: error.stack + message, + stack }, id }); @@ -76,7 +83,13 @@ async function initEnvironment() { await pyodide.loadPackage("shapely"); // Install IfcTester - await micropip.install('ifctester'); + const ifctesterManifest = await fetch('/worker/generated/ifctester.json').then((response) => { + if (!response.ok) { + throw new Error(`[worker] Failed to load IfcTester wheel manifest: ${response.status} ${response.statusText}`); + } + return response.json() as Promise<{ wheel_url: string }>; + }); + await micropip.install(ifctesterManifest.wheel_url); // Initialize IDS and API await API.init(pyodide); @@ -93,17 +106,17 @@ async function cleanupEnvironment() { console.log("[worker] Closed environment"); } -async function handleApiCall({ method, args = [] }) { +async function handleApiCall({ method, args = [] }: ApiCallPayload) { if (method === 'internal.cleanup') { await cleanupEnvironment(); return true; } if (method in API.API) { - return await API.API[method](...args); - } else if (method in IDS.API) { - return await IDS.API[method](...args); - } else { - throw new Error(`[worker] Unknown API method: ${method}`); + return await (API.API as Record unknown>)[method](...args); } -} \ No newline at end of file + if (method in IDS.API) { + return await (IDS.API as Record unknown>)[method](...args); + } + throw new Error(`[worker] Unknown API method: ${method}`); +} diff --git a/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte b/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte index 2bf687ecc0..df2cfbe9b0 100644 --- a/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte +++ b/src/ifctester/webapp/src/pages/Home/ApplicabilityPanel.svelte @@ -1,37 +1,56 @@ -
    @@ -40,22 +59,19 @@
    - {#if activeSpecification?.applicability} - {#each Object.entries(activeSpecification.applicability) as [facetType, facets]} - {#if facetType !== "@minOccurs" && facetType !== "@maxOccurs"} - {#each facets as facet, index} - - {/each} - {/if} + {#if activeSpecification && applicabilityEntries.length > 0} + {#each applicabilityEntries as [facetType, facets]} + {#each facets as facet, index} + + {/each} {/each} {/if}
    - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte b/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte index 4d807625b3..dbc6091f95 100644 --- a/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/FacetEditor.svelte @@ -1,20 +1,40 @@ -
    @@ -48,8 +68,8 @@
    - - getSpecialProp("@relation"), (v) => setSpecialProp("@relation", v)}> @@ -62,8 +82,8 @@ {#if activeTab === 'requirements'} {#if facetType !== 'entity'}
    - - getSpecialProp("@cardinality"), (v) => setSpecialProp("@cardinality", v)}> @@ -71,9 +91,9 @@
    {/if}
    - - + +
    {/if}
    -
    \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte b/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte index 59cd95c0a5..63b762fc5e 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsMetadataEditor.svelte @@ -1,13 +1,17 @@ - @@ -18,36 +22,36 @@
    - - getProp("title"), (v) => setProp("title", v)} placeholder="Enter IDS title"> + + getProp("title"), (v) => setProp("title", v)} placeholder="Enter IDS title">
    - - getProp("author"), (v) => setProp("author", v)} placeholder="Enter author"> + + getProp("author"), (v) => setProp("author", v)} placeholder="Enter author">
    - - getProp("version"), (v) => setProp("version", v)} placeholder="Enter version"> + + getProp("version"), (v) => setProp("version", v)} placeholder="Enter version">
    - - getProp("date"), (v) => setProp("date", v)}> + + getProp("date"), (v) => setProp("date", v)}>
    - - + +
    - - getProp("purpose"), (v) => setProp("purpose", v)} placeholder="Enter purpose"> + + getProp("purpose"), (v) => setProp("purpose", v)} placeholder="Enter purpose">
    - - getProp("milestone"), (v) => setProp("milestone", v)} placeholder="Enter milestone"> + + getProp("milestone"), (v) => setProp("milestone", v)} placeholder="Enter milestone">
    - - getProp("copyright"), (v) => setProp("copyright", v)} placeholder="Enter copyright"> + + getProp("copyright"), (v) => setProp("copyright", v)} placeholder="Enter copyright">
    diff --git a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte index 2fda5f0c2c..beadd151b8 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte @@ -1,18 +1,28 @@ -
    @@ -207,8 +254,17 @@
    {#each activeDocument.specifications.specification as spec, index} + {@const usage = getDocumentSpecificationUsage(spec)} + {@const requirementGroups = getRequirementGroups(spec)}
    -
    toggleSpecification(index)}> +
    toggleSpecification(index)} + onkeydown={(event) => handleActivation(event, () => toggleSpecification(index))} + >

    {spec["@name"] || `Specification ${index + 1}`}

    @@ -237,19 +293,19 @@

    {spec["@description"]}

    {/if}
    - {#if spec.applicability["@minOccurs"] === 1 && spec.applicability["@maxOccurs"] === 'unbounded'} + {#if usage === 'required'} Required {/if} - {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 'unbounded'} + {#if usage === 'optional'} Optional {/if} - {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 0} + {#if usage === 'prohibited'} Prohibited {/if} {#if auditReport} {@const stats = getSpecificationStats(index, auditReport.data)} {@const status = getSpecificationStatus(index, auditReport.data)} - {#if stats && spec.applicability["@maxOccurs"] !== 0 && status !== 'skipped'} + {#if stats && usage !== 'prohibited' && status !== 'skipped'} Checks: {stats.checksPassed}/{stats.checksTotal} Requirements: {stats.requirementsPassed}/{stats.requirements} {/if} @@ -302,12 +358,13 @@ {#if auditReport} {@const status = getSpecificationStatus(index, auditReport.data)} - {#if ! status && spec.applicability["@maxOccurs"] == 0} + {#if status === false && usage === 'prohibited'} {@const specReport = auditReport.data.specifications[index]} + {@const applicableEntities = specReport.applicable_entities ?? []}
    - {#if specReport.applicable_entities && specReport.applicable_entities.length > 0} + {#if applicableEntities.length > 0}
    -

    Failed Elements ({specReport.applicable_entities.length})

    +

    Failed Elements ({applicableEntities.length})

    @@ -323,7 +380,7 @@ - {#each specReport.applicable_entities.slice(0, 10) as entity} + {#each applicableEntities.slice(0, 10) as entity} @@ -379,9 +436,9 @@ {/each} - {#if specReport.applicable_entities.length > 10} + {#if applicableEntities.length > 10} - + {/if} @@ -397,37 +454,36 @@ - {#if Array.isArray(spec.requirements) && spec.requirements.length > 0} + {#if requirementGroups.length > 0}

    Requirements

    - {#each Object.entries(spec.requirements || {}) as [facetType, facets]} - {#if Array.isArray(facets) && facets.length > 0} -
    - {#each facets as facet, facetIndex} - {@const reqAuditData = auditReport ? getRequirementStatus(index, facetIndex, auditReport.data) : null} - {@const specStatus = auditReport ? getSpecificationStatus(index, auditReport.data) : null} -
    - - {#if isRequirementDetailsExpanded(index, facetIndex)} + {/if} + + {#if reqAuditData && isRequirementDetailsExpanded(index, item.reqIndex)}
    {#if reqAuditData.passed_entities && reqAuditData.passed_entities.length > 0} @@ -592,11 +648,10 @@ {/if}
    - {/if} -
    - {/each} -
    - {/if} + {/if} +
    + {/each} +
    {/each} diff --git a/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte b/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte index 189d9c7066..b034778a67 100644 --- a/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte +++ b/src/ifctester/webapp/src/pages/Home/RequirementsPanel.svelte @@ -1,37 +1,53 @@ -
    @@ -40,8 +56,8 @@
    - {#if activeSpecification?.requirements} - {#each Object.entries(activeSpecification.requirements) as [facetType, facets]} + {#if activeSpecification && requirementEntries.length > 0} + {#each requirementEntries as [facetType, facets]} {#each facets as facet, index} {/each} {/each} {/if}
    - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte b/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte index 3bc5042065..c8942b2a52 100644 --- a/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/RestrictionEditor.svelte @@ -1,34 +1,86 @@ -
    - -
    + {label} +
    {#if !isSpecialProp}
    - handleTypeChange((e.target as HTMLSelectElement).value)} + > @@ -477,7 +537,7 @@ placeholder={placeholder} /> {:else} - getSimpleValue(), (v) => setSimpleValue(v)} {placeholder}> + getSimpleValue(), (v) => setSimpleValue(v)} {placeholder} aria-label={label}> {/if} {:else if restrictionType === 'Enumeration'} @@ -497,7 +557,14 @@ placeholder={placeholder} /> {:else} - updateEnumerationValue(index, e.target.value)} {placeholder}> + updateEnumerationValue(index, (e.target as HTMLInputElement).value)} + {placeholder} + aria-label={`${label} option ${index + 1}`} + > {/if}
    {:else if restrictionType === 'Pattern'} - getPatternValue(), (v) => setPatternValue(v)} placeholder="Enter regex pattern (e.g., DT[0-9]{2})"> + getPatternValue(), (v) => setPatternValue(v)} placeholder="Enter regex pattern (e.g., DT[0-9]{2})" aria-label={`${label} pattern`}> {:else if restrictionType === 'Range'} + {@const range = getRangeValues()}
    - - getRangeValues().min, (v) => { const range = getRangeValues(); setRangeValues(v, range.max, range.minType, range.maxType); }} placeholder="0"> - range.min, (v) => setRangeValues(v, range.max, range.minType, range.maxType)} + placeholder="0" + > +
    - - getRangeValues().max, (v) => { const range = getRangeValues(); setRangeValues(range.min, v, range.minType, range.maxType); }} placeholder="0"> - range.max, (v) => setRangeValues(range.min, v, range.minType, range.maxType)} + placeholder="0" + > + @@ -538,17 +626,18 @@
    {:else if restrictionType === 'Length'} - getLengthValue(), (v) => setLengthValue(v)} placeholder="Enter exact length"> + getLengthValue(), (v) => setLengthValue(v)} placeholder="Enter exact length" aria-label={`${label} length`}> {:else if restrictionType === 'Length Range'} + {@const lengthRange = getLengthRangeValues()}
    - - getLengthRangeValues().min, (v) => { const range = getLengthRangeValues(); setLengthRangeValues(v, range.max); }} placeholder="0"> + + lengthRange.min, (v) => setLengthRangeValues(v, lengthRange.max)} placeholder="0">
    - - getLengthRangeValues().max, (v) => { const range = getLengthRangeValues(); setLengthRangeValues(range.min, v); }} placeholder="0"> + + lengthRange.max, (v) => setLengthRangeValues(lengthRange.min, v)} placeholder="0">
    {/if} @@ -656,4 +745,4 @@ color: #666; margin: 0; } - \ No newline at end of file + diff --git a/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte b/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte index be524ea411..cc340f6cbe 100644 --- a/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte +++ b/src/ifctester/webapp/src/pages/Home/SpecificationEditor.svelte @@ -1,33 +1,44 @@ - {#if IDS.Module.status != "ready"} @@ -130,8 +158,7 @@ Import from IDS - {#each Object.entries(IDS.Module.documents) as [docId, doc]} - {#if docId !== IDS.Module.activeDocument && doc.specifications?.specification?.length > 0} + {#each importableDocuments as [docId, doc], docIndex} {doc.info?.title || 'Untitled Document'} @@ -146,12 +173,11 @@ {/each} - {#if Object.entries(IDS.Module.documents).filter(([id, d]) => id !== IDS.Module.activeDocument && d.specifications?.specification?.length > 0).indexOf([docId, doc]) < Object.entries(IDS.Module.documents).filter(([id, d]) => id !== IDS.Module.activeDocument && d.specifications?.specification?.length > 0).length - 1} + {#if docIndex < importableDocuments.length - 1} {/if} - {/if} {/each} - {#if Object.entries(IDS.Module.documents).filter(([docId, doc]) => docId !== IDS.Module.activeDocument && doc.specifications?.specification?.length > 0).length === 0} + {#if importableDocuments.length === 0} No specifications available to import @@ -162,16 +188,30 @@
    -
    { if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); }}> +
    { if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); }} + onkeydown={(event) => handleActivation(event, () => { if (IDS.Module.activeDocument) IDS.setDocumentState(IDS.Module.activeDocument, { activeSpecification: null }); })} + > ā„¹ļø IDS Information
    {#if activeDocument?.specifications?.specification} {#each activeDocument.specifications.specification as spec, index} -
    selectSpecification(index)}> +
    selectSpecification(index)} + onkeydown={(event) => handleActivation(event, () => selectSpecification(index))} + > šŸ“„ {spec["@name"] || "Specification " + (index + 1)} - -
    @@ -209,18 +249,18 @@

    {activeSpecification ? activeSpecification["@name"] || "Specification" : "Specification"}

    - - - + + +
    {#if documentState?.activeTab === 'info'} {:else if documentState?.activeTab === 'applicability'} - + {:else if documentState?.activeTab === 'requirements'} - + {/if}
    {/if} diff --git a/src/ifctester/webapp/src/pages/index.js b/src/ifctester/webapp/src/pages/index.ts similarity index 100% rename from src/ifctester/webapp/src/pages/index.js rename to src/ifctester/webapp/src/pages/index.ts diff --git a/src/ifctester/webapp/src/routes.js b/src/ifctester/webapp/src/routes.ts similarity index 100% rename from src/ifctester/webapp/src/routes.js rename to src/ifctester/webapp/src/routes.ts diff --git a/src/ifctester/webapp/src/types/ids.ts b/src/ifctester/webapp/src/types/ids.ts new file mode 100644 index 0000000000..a8213e6baa --- /dev/null +++ b/src/ifctester/webapp/src/types/ids.ts @@ -0,0 +1,70 @@ +export type IdsCardinality = "required" | "optional" | "prohibited"; + +export type RestrictionValue = { + "@value": string; +}; + +export type Restriction = { + "@base"?: string; + enumeration?: RestrictionValue[]; + pattern?: RestrictionValue[]; + length?: RestrictionValue[]; + minLength?: RestrictionValue[]; + maxLength?: RestrictionValue[]; + minInclusive?: RestrictionValue[]; + maxInclusive?: RestrictionValue[]; + minExclusive?: RestrictionValue[]; + maxExclusive?: RestrictionValue[]; +}; + +export type SimpleValue = { + simpleValue: string; +}; + +export type FacetValue = { + simpleValue?: string; + restriction?: Restriction; +}; + +export type Facet = Record; + +export type FacetClause = Record; + +export type Specification = { + "@name"?: string; + "@identifier"?: string; + "@description"?: string; + "@instructions"?: string; + "@ifcVersion"?: string[]; + applicability?: FacetClause; + requirements?: FacetClause; +}; + +export type IdsInfo = { + title?: string; + copyright?: string; + version?: string; + description?: string; + author?: string; + date?: string; + purpose?: string; + milestone?: string; +}; + +export type IdsDocument = { + "@xmlns"?: string; + "@xmlns:xs"?: string; + "@xmlns:xsi"?: string; + "@xsi:schemaLocation"?: string; + info: IdsInfo; + specifications: { + specification: Specification[]; + }; +}; + +export type DocumentState = { + activeTab: "info" | "applicability" | "requirements"; + viewMode: "editor" | "viewer"; + activeSpecification: number | null; + auditReport?: string | null; +}; diff --git a/src/ifctester/webapp/src/types/report.ts b/src/ifctester/webapp/src/types/report.ts new file mode 100644 index 0000000000..ee683e0808 --- /dev/null +++ b/src/ifctester/webapp/src/types/report.ts @@ -0,0 +1,95 @@ +export type ResultsPercent = number | "N/A"; + +export type AuditReportEntity = { + reason?: string; + element?: unknown; + element_type?: unknown; + class?: string; + predefined_type?: string; + name?: string | null; + description?: string | null; + id?: number; + global_id?: string | null; + tag?: string | null; + type_name?: string; + type_tag?: string | null; + type_global_id?: string | null; + extra_of_type?: number; +}; + +export type AuditRequirement = { + facet_type: string; + metadata: Record; + label: string; + value: string; + description: string; + status: boolean; + passed_entities: AuditReportEntity[]; + failed_entities: AuditReportEntity[]; + total_applicable: number; + total_pass: number; + total_fail: number; + percent_pass: ResultsPercent; + instructions?: string; + total_failed_entities?: number; + total_omitted_failures?: number; + has_omitted_failures?: boolean; + total_passed_entities?: number; + total_omitted_passes?: number; + has_omitted_passes?: boolean; +}; + +export type AuditSpecification = { + name: string; + description: string; + instructions: string; + status: boolean; + is_skipped?: boolean; + is_ifc_version: boolean; + total_applicable: number; + total_applicable_pass: number; + total_applicable_fail: number; + percent_applicable_pass: ResultsPercent; + total_checks: number; + total_checks_pass: number; + total_checks_fail: number; + percent_checks_pass: ResultsPercent; + cardinality: string; + applicability: string[]; + applicable_entities?: AuditReportEntity[]; + requirements: AuditRequirement[]; + total_requirements?: number; + total_requirements_pass?: number; +}; + +export type AuditReportData = { + title: string; + date: string; + filepath: string | null; + filename: string | null; + hide_skipped: boolean; + specifications: AuditSpecification[]; + status: boolean; + total_specifications: number; + total_specifications_pass: number; + total_specifications_fail: number; + percent_specifications_pass: ResultsPercent; + total_requirements: number; + total_requirements_pass: number; + total_requirements_fail: number; + percent_requirements_pass: ResultsPercent; + total_checks: number; + total_checks_pass: number; + total_checks_fail: number; + percent_checks_pass: ResultsPercent; +}; + +export type AuditReport = { + id: string; + modelId: string; + modelName: string; + document: string; + date: string; + data: AuditReportData; + htmlReport?: string | null; +}; diff --git a/src/ifctester/webapp/src/types/wasm.ts b/src/ifctester/webapp/src/types/wasm.ts new file mode 100644 index 0000000000..101796bd11 --- /dev/null +++ b/src/ifctester/webapp/src/types/wasm.ts @@ -0,0 +1,24 @@ +export type WorkerMessageType = + | "init" + | "api_call" + | "ready" + | "api_response" + | "error" + | "disposed"; + +export type WorkerRequest = { + type: WorkerMessageType; + payload?: Record; + id: string; +}; + +export type WorkerResponse = { + type: WorkerMessageType; + payload?: Record; + id: string; +}; + +export type ApiCallPayload = { + method: string; + args?: unknown[]; +}; diff --git a/src/ifctester/webapp/tsconfig.json b/src/ifctester/webapp/tsconfig.json new file mode 100644 index 0000000000..39e15a0e10 --- /dev/null +++ b/src/ifctester/webapp/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "moduleResolution": "bundler", + "target": "ESNext", + "module": "ESNext", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "resolveJsonModule": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$src": ["./src"], + "$src/*": ["./src/*"] + } + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"] +} diff --git a/src/ifctester/webapp/vite.config.js b/src/ifctester/webapp/vite.config.js index cfc33f9356..46cecde534 100644 --- a/src/ifctester/webapp/vite.config.js +++ b/src/ifctester/webapp/vite.config.js @@ -1,7 +1,7 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; -import path from "path"; +import path from "node:path"; export default defineConfig({ plugins: [tailwindcss(), svelte()], @@ -11,4 +11,4 @@ export default defineConfig({ $src: path.resolve("./src"), }, }, -}); \ No newline at end of file +}); diff --git a/src/ifcwrap/IfcGeomWrapper.i b/src/ifcwrap/IfcGeomWrapper.i index e155c2837e..e992e4beab 100644 --- a/src/ifcwrap/IfcGeomWrapper.i +++ b/src/ifcwrap/IfcGeomWrapper.i @@ -1166,6 +1166,7 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type %ignore svgfill::line_segments_to_polygons; %ignore svgfill::svg_to_polygons; %ignore svgfill::arrange_polygons; +%ignore svgfill::abstract_arrangement; %template(svg_line_segments) std::vector>; %template(svg_groups_of_line_segments) std::vector>>; @@ -1287,9 +1288,9 @@ ifcopenshell::geometry::taxonomy::item::ptr try_upcast(PyObject* obj0, swig_type } } - std::vector arrange_polygons(const std::vector& polygons) { + std::vector arrange_polygons(svgfill::arrange_polygon_settings settings, const std::vector& polygons) { std::vector r; - if (svgfill::arrange_polygons(polygons, r)) { + if (svgfill::arrange_polygons(settings, polygons, r)) { return r; } else { throw std::runtime_error("Failed to arrange polygons"); diff --git a/src/ifcwrap/IfcPython.i b/src/ifcwrap/IfcPython.i index db772ba26e..ca802cedfa 100644 --- a/src/ifcwrap/IfcPython.i +++ b/src/ifcwrap/IfcPython.i @@ -108,6 +108,7 @@ %ignore FloatingPointDigits; %ignore BaseUri; %ignore WktUseSection; +%ignore SeparateZUpNode; // ConversionSettings.h %ignore MesherLinearDeflection; %ignore MesherAngularDeflection; @@ -158,11 +159,11 @@ %ignore CgalEmitOriginalEdges; %ignore OcctNoCleanTriangulation; %ignore CacheShapes; +%ignore MakeVolume; %ignore DeferProcessingFirstElement; %ignore MaxOffset; %ignore MaxOffsetDeviation; %ignore ApplyOffset; -%ignore SeparateZUpNode; %ignore XmlSerializerFactory; %ignore JsonSerializerFactory; diff --git a/src/opencdeserver/api/app/security/secure.py b/src/opencdeserver/api/app/security/secure.py index fec837b868..e332c94647 100644 --- a/src/opencdeserver/api/app/security/secure.py +++ b/src/opencdeserver/api/app/security/secure.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from database.neo4j import db from fastapi import Depends, HTTPException, Security, status @@ -33,10 +33,8 @@ credentials_exception = HTTPException( def create_access_token(data: dict, expires_delta: timedelta | None = None): payload = data.copy() - if expires_delta: - expire = datetime.utcnow() + expires_delta - else: - expire = datetime.utcnow() + timedelta(minutes=15) + expires_delta = expires_delta or timedelta(minutes=15) + expire = datetime.now(timezone.utc) + expires_delta payload.update({"expires": str(expire)}) encoded_jwt = jwt.encode(payload, secrets["security_secret_key"], algorithm=os.environ["SECURITY_ALGORITHM"]) return encoded_jwt diff --git a/src/serializers/WavefrontObjSerializer.cpp b/src/serializers/WavefrontObjSerializer.cpp index f29612a65e..848dd9beb7 100644 --- a/src/serializers/WavefrontObjSerializer.cpp +++ b/src/serializers/WavefrontObjSerializer.cpp @@ -32,6 +32,7 @@ WaveFrontOBJSerializer::WaveFrontOBJSerializer(const stream_or_filename& obj_fil , obj_stream(obj_filename) , mtl_stream(mtl_filename) , vcount_total(1) + , ncount_total(1) { obj_stream.stream << std::setprecision(settings.get().get()); mtl_stream.stream << std::setprecision(settings.get().get()); diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 4fa20c68dc..cfb51321f8 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -350,6 +350,8 @@ find_overlaps(const std::vector& polygons) { class DebugWriter { public: + DebugWriter() : enabled_(false) {} + DebugWriter(bool enabled, const std::string& filename_prefix) : enabled_(enabled) { if (enabled_) { @@ -368,6 +370,43 @@ class DebugWriter { } } + DebugWriter(const DebugWriter&) = delete; + + DebugWriter(DebugWriter&& other) noexcept + : obj(std::move(other.obj)), vi(other.vi), svg(std::move(other.svg)), enabled_(other.enabled_), last_segment_name_(std::move(other.last_segment_name_)) + { + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + } + + DebugWriter& operator=(const DebugWriter&) = delete; + + DebugWriter& operator=(DebugWriter&& other) noexcept { + if (this == &other) { + return *this; + } + + if (enabled_) { + svg << "\n"; + obj << std::flush; + obj.close(); + svg.close(); + } + + obj = std::move(other.obj); + svg = std::move(other.svg); + vi = other.vi; + enabled_ = other.enabled_; + last_segment_name_ = std::move(other.last_segment_name_); + + other.enabled_ = false; + other.vi = 1; + other.last_segment_name_.clear(); + + return *this; + } + void write_polygon(const Polygon_2& polygon, const std::string& name) { if (enabled_) { write_polygon_to_obj_(obj, vi, true, polygon, name); @@ -387,12 +426,21 @@ class DebugWriter { obj << "l " << vi++; obj << " " << vi++ << "\n"; - svg << ""; + svg << "\n"; obj << std::flush; } } + void write_point(const Point_2& p, const std::string& name) { + if (enabled_) { + obj << "o " << name << "\n"; + obj << "v " << CGAL::to_double(p.x()) << " " << CGAL::to_double(p.y()) << " 0\n"; + vi++; + svg << "\n"; + } + } + void write_polygon(const Polygon_with_holes_2& polygon, const std::string& name) { if (enabled_) { write_polygon(polygon.outer_boundary(), name); @@ -413,6 +461,20 @@ class DebugWriter { } } + void write_polygons(const Arrangement_2& arr, const std::string& name) { + if (enabled_) { + // Just for the automatic numbering, create a full vector + std::vector temp; + for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) { + if (it->is_unbounded()) { + continue; + } + temp.push_back(circ_to_poly(it->outer_ccb())); + } + write_polygons(temp, name); + } + } + void write_polygons(const std::vector& polygons, const std::string& name) { if (enabled_) { size_t i = 0; @@ -436,7 +498,14 @@ class DebugWriter { std::string last_segment_name_; void write_polygon_to_svg_(std::ostream& ofs, const Polygon_2& polygon, const std::string& class_name = "") { - ofs << "x()) << "," << -CGAL::to_double(vit->y()) << " "; } @@ -468,7 +537,7 @@ class DebugWriter { } }; -void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { +void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DISTANCE, std::vector& polygons) { // solve overlaps by means of subtraction // loop over overlaps and subtract the smaller polygon from the larger one @@ -576,11 +645,37 @@ void eliminate_overlaps(double OVERLAP_RESOLUTION_DISTANCE, std::vector(25, 27); + bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { + if (is_) { + debug_writer.write_polygon(*mp1, "mp1"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp1); + if (is_) { + debug_writer.write_polygon(*mp1, "mp1b"); + } if ((mp2 = subtract_retain_largest(*poly1, *mp1))) { + if (is_) { + debug_writer.write_polygon(*mp2, "mp2"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp2); + if (is_) { + debug_writer.write_polygon(*mp2, "mp2b"); + } if ((mp3 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE * 2, *mp2)))) { + if (is_) { + debug_writer.write_polygon(*mp3, "mp3"); + } + smooth_polygon(OVERLAP_RESOLUTION_DISTANCE / 100., *mp3); + if (is_) { + debug_writer.write_polygon(*mp3, "mp3b"); + } if ((mp4 = subtract_retain_largest(*poly2, *mp3))) { + if (is_) { + debug_writer.write_polygon(*mp4, "mp4"); + } *poly1 = *mp2; *poly2 = *mp4; success = true; @@ -739,6 +834,10 @@ class SegmentLookup { return out; } + PolygonIt end() const { + return polygons_ref_.end(); + } + private: using TreeTraits = CGAL::AABB_traits>::iterator>>; using Tree = CGAL::AABB_tree; @@ -751,25 +850,33 @@ private: std::map::const_iterator> input_polygon_boundary_cache_; }; -Polygon_2 subdivide_polygon(double max_distance, const Polygon_2 & p) { +Polygon_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_2& p, std::map& point_lookup) { std::vector points; for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source_poly = segment_lookup.input_polygon_boundary(it->source()); + auto target_poly = segment_lookup.input_polygon_boundary(it->target()); const auto& seg = *it; - auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1; points.push_back(seg.source()); - for (auto i = 0; i < num_splits; ++i) { - auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1); - points.push_back(seg.source() + d); + if (source_poly == target_poly && source_poly != segment_lookup.end()) { + point_lookup.emplace(seg.source(), source_poly); + point_lookup.emplace(seg.target(), source_poly); + auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1; + for (auto i = 0; i < num_splits; ++i) { + auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1); + auto p = seg.source() + d; + point_lookup.emplace(p, source_poly); + points.push_back(p); + } } } return Polygon_2(points.begin(), points.end()); }; -Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_holes_2& pwh) { - Polygon_2 outer = subdivide_polygon(max_distance, pwh.outer_boundary()); +Polygon_with_holes_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_with_holes_2& pwh, std::map& point_lookup) { + Polygon_2 outer = subdivide_polygon_on_same_input(segment_lookup, max_distance, pwh.outer_boundary(), point_lookup); std::vector holes; for (auto hit = pwh.holes_begin(); hit != pwh.holes_end(); ++hit) { - holes.push_back(subdivide_polygon(max_distance, *hit)); + holes.push_back(subdivide_polygon_on_same_input(segment_lookup, max_distance, *hit, point_lookup)); } return Polygon_with_holes_2(outer, holes.begin(), holes.end()); }; @@ -777,14 +884,19 @@ Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_h std::tuple< std::map>, std::map>, - std::map, std::vector*>>> -build_line_graph(const std::vector& input_polygons, SegmentLookup& segment_lookup, const std::vector& triangular_polygons) { + std::map, std::vector*>>, + std::map +> +build_line_graph(const std::vector& input_polygons, const std::map& point_lookup, const std::vector& triangular_polygons) +{ + // Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh' std::map, std::vector*>> segment_to_facet; std::map, std::vector*>> segment_to_input_facet; std::map, Point_2> segment_to_midpoint; std::map> midpoint_to_segment; std::map*, std::vector>> facet_to_segment; + std::map midpoint_to_edge_length; for (auto& tri : triangular_polygons) { for (size_t i = 0; i < 3; ++i) { @@ -804,15 +916,20 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se for (auto& p : segment_to_facet) { auto center = CGAL::ORIGIN + (((p.first.first - CGAL::ORIGIN) + (p.first.second - CGAL::ORIGIN)) / 2); - auto p1index = segment_lookup.input_polygon_boundary(p.first.first); - auto p2index = segment_lookup.input_polygon_boundary(p.first.second); + auto p1index = point_lookup.find(p.first.first); + auto p2index = point_lookup.find(p.first.second); - segment_to_input_facet[p.first].push_back(&*p1index); - segment_to_input_facet[p.first].push_back(&*p2index); + if (p1index == point_lookup.end() || p2index == point_lookup.end()) { + continue; + } - if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) { + segment_to_input_facet[p.first].push_back(&*p1index->second); + segment_to_input_facet[p.first].push_back(&*p2index->second); + + if (p1index->second != input_polygons.end() && p2index->second != input_polygons.end() && p1index->second != p2index->second) { segment_to_midpoint[p.first] = center; midpoint_to_segment[center] = p.first; + midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second))); } } @@ -832,7 +949,730 @@ build_line_graph(const std::vector& input_polygons, SegmentLookup& se } } - return {line_graph, midpoint_to_segment, segment_to_input_facet}; + return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length}; +} + +using DPoint = CGAL::Simple_cartesian::Point_2; +using DDir = CGAL::Simple_cartesian::Vector_2; +using DBox = std::array; + +struct CenterLineGraphData { + std::vector points; + std::vector points_double; + std::vector widths; + std::vector> edges; + std::vector> incident_edges; +}; + +struct LineRun { + Point_2 start_exact; + Point_2 end_exact; + DPoint start; + DPoint end; + DDir direction; + double avg_width; + double length; + size_t vertex_count; +}; + +struct RunBoxRecord { + size_t run_index; + DPoint start; + DPoint end; + DDir direction; + double width; + double length; + std::array corners; + DBox bbox; +}; + +struct MergedBoxRecord { + DPoint start; + DPoint end; + DDir direction; + DDir normal; + double avg_width; + double length; + size_t member_count; + std::vector members; + std::array corners; + DBox bbox; + Point_2 exact_start; + Point_2 exact_end; +}; + +struct BoxCluster { + std::vector members; + MergedBoxRecord box; +}; + +struct SnapCandidate { + size_t box_index; + double box_distance; + double line_distance; + Point_2 projection; +}; + +DDir unit(const DDir& a) { + auto n = std::sqrt(a.squared_length()); + if (n < 1.e-9) { + return {0., 0.}; + } + return a / n; +} + +DDir perpendicular(const DDir& a) { + return DDir(-a.y(), a.x()); +} + +DDir canonicalize_like(const DDir& a, const DDir& ref) { + return (a * ref) < 0. ? -a : a; +} + +DPoint to_double_point(const Point_2& p) { + return {CGAL::to_double(p.x()), CGAL::to_double(p.y())}; +} + +Point_2 to_exact_point(const DPoint& p) { + return Point_2(p.x(), p.y()); +} + +double point_line_distance(const DPoint& p, const DPoint& line_point, const DDir& line_dir) { + auto u = unit(line_dir); + auto delta = (p - line_point); + if (u.squared_length() < 1.e-18) { + return std::sqrt(delta.squared_length()); + } + return std::abs(CGAL::determinant(u.x(), u.y(), delta.x(), delta.y())); +} + +double angle_between_dirs_deg(const DDir& a, const DDir& b) { + auto u = unit(a); + auto v = unit(b); + auto c = std::abs(u * v); + if (c > 1.) { + c = 1.; + } + return std::acos(c) * 180. / 3.14159265358979323846; +} + +std::array rectangle_corners(const DPoint& start, const DPoint& end, double width) { + auto u = unit(end - start); + if (u.squared_length() < 1.e-18) { + u = {1., 0.}; + } + auto n = perpendicular(u); + auto ext = width; + auto p0 = start - u * ext; + auto p1 = end + u * ext; + auto w = n * (width / 2.); + return {p0 + w, p1 + w, p1 - w, p0 - w}; +} + +DBox aabb_from_points(const std::array& corners) { + DBox bbox{corners[0], corners[0]}; + for (auto& p : corners) { + bbox[0] = {std::min(bbox[0].x(), p.x()), std::min(bbox[0].y(), p.y())}; + bbox[1] = {std::max(bbox[1].x(), p.x()), std::max(bbox[1].y(), p.y())}; + } + return bbox; +} + +bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) { + return a[0].x() <= b[1].x() + eps && + a[1].x() + eps >= b[0].x() && + a[0].y() <= b[1].y() + eps && + a[1].y() + eps >= b[0].y(); +} + +std::pair projected_interval_on_axis(const std::array& points, const DDir& axis_u) { + auto u = unit(axis_u); + auto t0 = (points.front() - CGAL::ORIGIN) * u; + auto interval = std::make_pair(t0, t0); + for (auto& p : points) { + auto t = (p - CGAL::ORIGIN) * u; + interval.first = std::min(interval.first, t); + interval.second = std::max(interval.second, t); + } + return interval; +} + +bool intervals_overlap(const std::pair& a, const std::pair& b, double eps = 1.e-9) { + return a.first <= b.second + eps && b.first <= a.second + eps; +} + +bool obb_overlap(const std::array& a, const std::array& b, double eps = 1.e-9) { + auto has_separating_axis = [&](const std::array& points) { + for (size_t i = 0; i < points.size(); ++i) { + auto edge = points[(i + 1) % points.size()] - points[i]; + auto axis = unit(perpendicular(edge)); + if (axis.squared_length() < 1.e-18) { + continue; + } + if (!intervals_overlap(projected_interval_on_axis(a, axis), projected_interval_on_axis(b, axis), eps)) { + return true; + } + } + return false; + }; + + return !has_separating_axis(a) && !has_separating_axis(b); +} + +template +bool obb_overlap(const T& a, const U& b, double eps = 1.e-9) { + return obb_overlap(a.corners, b.corners, eps); +} + +CenterLineGraphData make_center_line_graph_data( + const std::map>& line_graph, + const std::map& midpoint_to_edge_length) +{ + CenterLineGraphData graph; + std::map point_to_index; + + auto ensure_point = [&](const Point_2& p) { + auto it = point_to_index.find(p); + if (it != point_to_index.end()) { + return it->second; + } + auto i = graph.points.size(); + point_to_index[p] = i; + graph.points.push_back(p); + graph.points_double.push_back(to_double_point(p)); + auto wt = midpoint_to_edge_length.find(p); + graph.widths.push_back(wt == midpoint_to_edge_length.end() ? 0. : wt->second); + graph.incident_edges.emplace_back(); + return i; + }; + + for (auto& p : line_graph) { + ensure_point(p.first); + for (auto& q : p.second) { + ensure_point(q); + } + } + + std::set> seen_edges; + for (auto& p : line_graph) { + auto i = ensure_point(p.first); + for (auto& q : p.second) { + auto j = ensure_point(q); + if (i == j) { + continue; + } + auto e = i < j ? std::make_pair(i, j) : std::make_pair(j, i); + if (seen_edges.insert(e).second) { + auto k = graph.edges.size(); + graph.edges.push_back(e); + graph.incident_edges[e.first].push_back(k); + graph.incident_edges[e.second].push_back(k); + } + } + } + + return graph; +} + +double segment_width(const CenterLineGraphData& graph, const std::pair& edge) { + return 0.5 * (graph.widths[edge.first] + graph.widths[edge.second]); +} + +bool edge_supports_same_line( + const DPoint& seed_a, + const DPoint& seed_b, + const DPoint& test_a, + const DPoint& test_b, + double angle_tol_deg = 3., + double line_dist_tol = 0.15) +{ + auto d_seed = seed_b - seed_a; + auto d_test = test_b - test_a; + if (d_seed.squared_length() < 1.e-18 || d_test.squared_length() < 1.e-18) { + return false; + } + if (angle_between_dirs_deg(d_seed, d_test) > angle_tol_deg) { + return false; + } + return + point_line_distance(test_a, seed_a, d_seed) <= line_dist_tol && + point_line_distance(test_b, seed_a, d_seed) <= line_dist_tol; +} + +std::vector runs_from_graph(const CenterLineGraphData& graph, double angle_tol_deg = 3., double line_dist_tol = 0.15) { + std::vector visited(graph.edges.size(), false); + std::vector runs; + + for (size_t seed_ei = 0; seed_ei < graph.edges.size(); ++seed_ei) { + if (visited[seed_ei]) { + continue; + } + + const auto& seed_edge = graph.edges[seed_ei]; + auto seed_a = graph.points_double[seed_edge.first]; + auto seed_b = graph.points_double[seed_edge.second]; + auto seed_dir = seed_b - seed_a; + if (seed_dir.squared_length() < 1.e-18) { + visited[seed_ei] = true; + continue; + } + + std::vector queue = {seed_ei}; + std::set component_edges; + + while (!queue.empty()) { + auto ei = queue.back(); + queue.pop_back(); + if (!component_edges.insert(ei).second) { + continue; + } + + const auto& edge = graph.edges[ei]; + std::array vertices = {edge.first, edge.second}; + for (auto v : vertices) { + for (auto ej : graph.incident_edges[v]) { + if (ej == ei || visited[ej] || component_edges.count(ej)) { + continue; + } + const auto& candidate = graph.edges[ej]; + auto test_a = graph.points_double[candidate.first]; + auto test_b = graph.points_double[candidate.second]; + if (edge_supports_same_line(seed_a, seed_b, test_a, test_b, angle_tol_deg, line_dist_tol)) { + queue.push_back(ej); + } + } + } + } + + for (auto ei : component_edges) { + visited[ei] = true; + } + + std::set component_vertices; + auto ref = unit(seed_dir); + DDir direction_sum{0., 0.}; + double total_length = 0.; + double weighted_width_sum = 0.; + + for (auto ei : component_edges) { + const auto& edge = graph.edges[ei]; + component_vertices.insert(edge.first); + component_vertices.insert(edge.second); + + auto d = graph.points_double[edge.second] - graph.points_double[edge.first]; + auto u = canonicalize_like(unit(d), ref); + direction_sum = direction_sum + u; + + auto len = std::sqrt(d.squared_length()); + total_length += len; + weighted_width_sum += len * segment_width(graph, edge); + } + + auto run_direction = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum); + + double min_t = std::numeric_limits::infinity(); + double max_t = -std::numeric_limits::infinity(); + size_t start_index = *component_vertices.begin(); + size_t end_index = start_index; + for (auto vi : component_vertices) { + auto t = (graph.points_double[vi] - CGAL::ORIGIN) * run_direction; + if (t < min_t) { + min_t = t; + start_index = vi; + } + if (t > max_t) { + max_t = t; + end_index = vi; + } + } + + auto avg_width = total_length < 1.e-9 ? segment_width(graph, seed_edge) : weighted_width_sum / total_length; + + runs.push_back({ + graph.points[start_index], + graph.points[end_index], + graph.points_double[start_index], + graph.points_double[end_index], + run_direction, + avg_width, + std::sqrt((graph.points_double[end_index] - graph.points_double[start_index]).squared_length()), + component_vertices.size() + }); + } + + return runs; +} + +std::vector build_run_box_records(const std::vector& runs) { + std::vector records; + records.reserve(runs.size()); + for (size_t i = 0; i < runs.size(); ++i) { + auto corners = rectangle_corners(runs[i].start, runs[i].end, runs[i].avg_width); + records.push_back({ + i, + runs[i].start, + runs[i].end, + unit(runs[i].end - runs[i].start), + runs[i].avg_width, + runs[i].length, + corners, + aabb_from_points(corners) + }); + } + return records; +} + +template +std::pair projected_interval_on_axis(const T& box, const DDir& axis_u) { + auto u = unit(axis_u); + auto ta = (box.start - CGAL::ORIGIN) * u; + auto tb = (box.end - CGAL::ORIGIN) * u; + return {std::min(ta, tb), std::max(ta, tb)}; +} + +double interval_overlap_length(const std::pair& a, const std::pair& b) { + return std::max(0., std::min(a.second, b.second) - std::max(a.first, b.first)); +} + +template +double boxes_overlap_along_merge_axis(const T& a, const T& b) { + auto d1 = unit(a.end - a.start); + auto d2 = unit(b.end - b.start); + if (d1 * d2 < 0.) { + d2 = {-d2.x(), -d2.y()}; + } + auto merge_axis = unit(d1 + d2); + if (merge_axis.squared_length() < 1.e-18) { + merge_axis = d1; + } + + auto i1 = projected_interval_on_axis(a, merge_axis); + auto i2 = projected_interval_on_axis(b, merge_axis); + auto overlap = interval_overlap_length(i1, i2); + auto small_length = std::min(i1.second - i1.first, i2.second - i2.first); + if (small_length < 1.e-9) { + return false; + } + return overlap / small_length; +} + +MergedBoxRecord merge_cluster_to_box(const std::vector& member_indices, const std::vector& records) { + auto ref = records[member_indices.front()].direction; + DDir direction_sum{0., 0.}; + for (auto i : member_indices) { + auto u = canonicalize_like(records[i].direction, ref); + direction_sum = direction_sum + u * std::max(records[i].length, 1.e-9); + } + + auto u = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum); + auto n = perpendicular(u); + + double tmin = std::numeric_limits::infinity(); + double tmax = -std::numeric_limits::infinity(); + double smin = std::numeric_limits::infinity(); + double smax = -std::numeric_limits::infinity(); + + for (auto i : member_indices) { + for (auto& corner : records[i].corners) { + auto t = (corner - CGAL::ORIGIN) * u; + auto s = (corner - CGAL::ORIGIN) * n; + tmin = std::min(tmin, t); + tmax = std::max(tmax, t); + smin = std::min(smin, s); + smax = std::max(smax, s); + } + } + + auto width = smax - smin; + auto sc = (smin + smax) / 2.; + auto start = u * tmin + n * sc; + auto end = u * tmax + n * sc; + auto corners = rectangle_corners(CGAL::ORIGIN + start, CGAL::ORIGIN + end, width); + + MergedBoxRecord box{ + CGAL::ORIGIN + start, + CGAL::ORIGIN + end, + u, + n, + width, + std::sqrt((end - start).squared_length()), + member_indices.size(), + member_indices, + corners, + aabb_from_points(corners), + to_exact_point(CGAL::ORIGIN + start), + to_exact_point(CGAL::ORIGIN + end) + }; + return box; +} + +std::pair merge_score(const MergedBoxRecord& a, const MergedBoxRecord& b) { + auto ang = angle_between_dirs_deg(a.direction, b.direction); + auto center_a = ((a.start - CGAL::ORIGIN) + (a.end - CGAL::ORIGIN)) / 2.; + auto center_b = ((b.start - CGAL::ORIGIN) + (b.end - CGAL::ORIGIN)) / 2.; + return {ang, std::sqrt((center_b - center_a).squared_length())}; +} + +bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_tol_deg = 5., double axis_overlap_ratio_limit = 0.5) { + if (!aabb_overlap(a.box.bbox, b.box.bbox)) { + return false; + } + if (!obb_overlap(a.box, b.box)) { + return false; + } + if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) { + return false; + } + if (boxes_overlap_along_merge_axis(a.box, b.box) > axis_overlap_ratio_limit) { + auto a_center = CGAL::ORIGIN + ((a.box.start - CGAL::ORIGIN) + (a.box.end - CGAL::ORIGIN)) / 2.; + auto b_center = CGAL::ORIGIN + ((b.box.start - CGAL::ORIGIN) + (b.box.end - CGAL::ORIGIN)) / 2.; + auto a_dir = a.box.direction; + auto b_dir = b.box.direction; + auto dist = a.box.length < b.box.length ? point_line_distance(a_center, b_center, b_dir) : point_line_distance(b_center, a_center, a_dir); + auto ref = a.box.length < b.box.length ? a.box.avg_width : b.box.avg_width; + return dist < (ref / 4.); + } + return true; +} + +std::vector merge_intersecting_parallel_boxes_iterative(const std::vector& runs) { + auto records = build_run_box_records(runs); + std::vector clusters; + clusters.reserve(records.size()); + for (size_t i = 0; i < records.size(); ++i) { + clusters.push_back({{i}, merge_cluster_to_box({i}, records)}); + } + + while (true) { + std::optional> best_pair; + std::pair best_score; + + for (size_t i = 0; i < clusters.size(); ++i) { + for (size_t j = i + 1; j < clusters.size(); ++j) { + if (!clusters_can_merge(clusters[i], clusters[j])) { + continue; + } + auto score = merge_score(clusters[i].box, clusters[j].box); + if (!best_pair || score < best_score) { + best_pair = std::make_pair(i, j); + best_score = score; + } + } + } + + if (!best_pair) { + break; + } + + auto i = best_pair->first; + auto j = best_pair->second; + std::vector members = clusters[i].members; + members.insert(members.end(), clusters[j].members.begin(), clusters[j].members.end()); + auto merged = BoxCluster{members, merge_cluster_to_box(members, records)}; + + std::vector next_clusters; + next_clusters.reserve(clusters.size() - 1); + for (size_t k = 0; k < clusters.size(); ++k) { + if (k != i && k != j) { + next_clusters.push_back(std::move(clusters[k])); + } + } + next_clusters.push_back(std::move(merged)); + clusters = std::move(next_clusters); + } + + std::vector merged_boxes; + merged_boxes.reserve(clusters.size()); + for (auto& cluster : clusters) { + merged_boxes.push_back(cluster.box); + } + return merged_boxes; +} + +Point_2 project_point_to_line_exact(const Point_2& p, const MergedBoxRecord& box) { + auto d = box.exact_end - box.exact_start; + if (d.squared_length() == 0) { + return box.exact_start; + } + auto t = ((p - box.exact_start) * d) / d.squared_length(); + return box.exact_start + d * t; +} + +boost::optional intersect_infinite_lines_exact(const MergedBoxRecord& a, const MergedBoxRecord& b) { + if (a.exact_start == a.exact_end || b.exact_start == b.exact_end) { + return boost::none; + } + auto x = CGAL::intersection(CGAL::Line_2(a.exact_start, a.exact_end), CGAL::Line_2(b.exact_start, b.exact_end)); + if (!x) { + return boost::none; + } + if (auto* xp = variant_get(&*x)) { + return *xp; + } + return boost::none; +} + +double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& box) { + auto d = box.end - box.start; + auto L = std::sqrt(d.squared_length()); + if (L < 1.e-9) { + return std::sqrt((p - box.start).squared_length()); + } + + auto u = d / L; + auto n = perpendicular(u); + auto rel = p - box.start; + auto t = rel * u; + auto s = rel * n; + + auto tmin = -box.avg_width / 2.; + auto tmax = L + box.avg_width / 2.; + auto smin = -box.avg_width / 2.; + auto smax = box.avg_width / 2.; + + double dt = 0.; + if (t < tmin) { + dt = tmin - t; + } else if (t > tmax) { + dt = t - tmax; + } + + double ds = 0.; + if (s < smin) { + ds = smin - s; + } else if (s > smax) { + ds = s - smax; + } + + return std::hypot(dt, ds); +} + +std::map> snap_points_to_box_axes( + const CenterLineGraphData& graph, + const std::vector& boxes, + const K::FT& max_projection_distance) { + std::vector snapped_points(graph.points.size()); + + for (size_t i = 0; i < graph.points.size(); ++i) { + if (boxes.empty()) { + snapped_points[i] = graph.points[i]; + continue; + } + + std::vector candidates; + candidates.reserve(boxes.size()); + for (size_t j = 0; j < boxes.size(); ++j) { + candidates.push_back({ + j, + point_to_oriented_box_distance(graph.points_double[i], boxes[j]), + point_line_distance(graph.points_double[i], boxes[j].start, boxes[j].direction), + project_point_to_line_exact(graph.points[i], boxes[j]) + }); + } + + std::vector containing; + for (auto& candidate : candidates) { + if (candidate.box_distance <= 1.e-9) { + containing.push_back(candidate); + } + } + + auto less = [](const SnapCandidate& a, const SnapCandidate& b) { + if (a.line_distance != b.line_distance) { + return a.line_distance < b.line_distance; + } + return a.box_distance < b.box_distance; + }; + + if (containing.size() >= 2) { + std::sort(containing.begin(), containing.end(), less); + auto& c1 = containing[0]; + auto& c2 = containing[1]; + if (angle_between_dirs_deg(boxes[c1.box_index].direction, boxes[c2.box_index].direction) > 8.) { + if (auto x = intersect_infinite_lines_exact(boxes[c1.box_index], boxes[c2.box_index])) { + snapped_points[i] = *x; + continue; + } + } + snapped_points[i] = c1.projection; + continue; + } + + if (containing.size() == 1) { + snapped_points[i] = containing[0].projection; + continue; + } + + auto best = *std::min_element(candidates.begin(), candidates.end(), [](const SnapCandidate& a, const SnapCandidate& b) { + if (a.box_distance != b.box_distance) { + return a.box_distance < b.box_distance; + } + return a.line_distance < b.line_distance; + }); + + if ((graph.points[i] - best.projection).squared_length() < (max_projection_distance * max_projection_distance)) { + snapped_points[i] = best.projection; + } else { + snapped_points[i] = graph.points[i]; + std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl; + } + } + + std::map> adjacency; + for (auto& edge : graph.edges) { + auto a = snapped_points[edge.first]; + auto b = snapped_points[edge.second]; + if (a == b) { + continue; + } + adjacency[a].insert(b); + adjacency[b].insert(a); + } + + std::map> snapped_graph; + for (auto& p : adjacency) { + snapped_graph[p.first] = {p.second.begin(), p.second.end()}; + } + return snapped_graph; +} + +Graph2D join_segment_runs( + DebugWriter& debug, + const std::map>& line_graph, + const std::map& midpoint_to_edge_length, + const K::FT& max_projection_distance) { + auto graph = make_center_line_graph_data(line_graph, midpoint_to_edge_length); + auto runs = runs_from_graph(graph); + runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) { + return run.vertex_count <= 5; + }), runs.end()); + + std::vector run_polygons; + for (auto& r : runs) { + auto ps = rectangle_corners(r.start, r.end, r.avg_width); + std::array exact_corners; + std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) { + return to_exact_point(p); + }); + run_polygons.emplace_back(exact_corners.begin(), exact_corners.end()); + } + debug.write_polygons(run_polygons, "initial_runs"); + run_polygons.clear(); + + auto boxes = merge_intersecting_parallel_boxes_iterative(runs); + + for (auto& r : boxes) { + auto ps = rectangle_corners(r.start, r.end, r.avg_width); + std::array exact_corners; + std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) { + return to_exact_point(p); + }); + run_polygons.emplace_back(exact_corners.begin(), exact_corners.end()); + } + debug.write_polygons(run_polygons, "merged_boxes"); + + auto snapped_graph = snap_points_to_box_axes(graph, boxes, max_projection_distance); + return Graph2D(snapped_graph); } std::set> find_triangles(const std::map>& line_graph) { @@ -1118,66 +1958,88 @@ std::list> extend_end_vertices_based_on_input( const Graph2D& G, const std::map>& midpoint_to_segment, const std::map, std::vector*>>& segment_to_input_facet, - const Polygon_list& inner_offset, - const SegmentLookup& segment_lookup + const Polygon_list& outer_perimiter, + const SegmentLookup& segment_lookup, + const K::FT& max_projection_distance ){ std::list> constructed_segments; - for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { - if (it->second.size() == 1) { - auto& M = it->first; + std::set processed_vertices; - const std::pair* q = nullptr; + while (true) { + // The idea was to peal off 1-degree vertices when projecting them did not result into + // nearby intersections with the outer perimiter. This in case there would be turns near + // the perimeter, which would be eliminated by pealing off the vertices, which would then + // require out of the loop because of invalidated iterators. For now we decided to stick + // to a projection of the vertex onto the perimeter segment when the projection distance + // exceeds a threshold. + bool broke_out = false; - if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { - typename K::FT min_sq_distance = std::numeric_limits::infinity(); - for (auto& pa : midpoint_to_segment) { - if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { - q = &pa.second; - min_sq_distance = CGAL::squared_distance(pa.first, M); - } + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; + + if (processed_vertices.find(M) != processed_vertices.end()) { + continue; } - } else { - q = &midpoint_to_segment.find(M)->second; - } - if (q == nullptr) { - continue; - } + const std::pair* q = nullptr; - bool handled_as_graph_path = false; + if (midpoint_to_segment.find(M) == midpoint_to_segment.end()) { + typename K::FT min_sq_distance = std::numeric_limits::infinity(); + for (auto& pa : midpoint_to_segment) { + if (CGAL::squared_distance(pa.first, M) < min_sq_distance) { + q = &pa.second; + min_sq_distance = CGAL::squared_distance(pa.first, M); + } + } + } else { + q = &midpoint_to_segment.find(M)->second; + } - // distance from unioned - shoot ray? - if (segment_to_input_facet.find(*q)->second.size() == 2) { - for (auto& bnd : inner_offset) { - // if point M is contained in bnd interior: - // if (!bnd.has_on_unbounded_side(M)) { - if (bnd.has_on_bounded_side(M)) { - auto& incoming = *it->second.begin(); - // create ray incoming -> M - CGAL::Ray_2 ray(incoming, M - incoming); - // intersect ray with boundary - boost::optional> closest_segment; - boost::optional> closest_intersection_point; - K::FT sq_distance_along_ray = std::numeric_limits::infinity(); - for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { - const auto& seg = *jt; - auto x = CGAL::intersection(ray, seg); - if (x) { - if (auto* xp = variant_get>(&*x)) { - auto dist = ((*xp) - M).squared_length(); - if (dist < sq_distance_along_ray) { - closest_segment = seg; - closest_intersection_point = *xp; - sq_distance_along_ray = dist; + if (q == nullptr) { + continue; + } + + bool handled_as_graph_path = false; + + // distance from unioned - shoot ray? + if (segment_to_input_facet.find(*q)->second.size() == 2) { + for (auto& bnd : outer_perimiter) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + auto& incoming = *it->second.begin(); + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + if (dist < sq_distance_along_ray) { + if (dist < (max_projection_distance * max_projection_distance)) { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; + } else { + + } + } } } } - } - if (closest_intersection_point) { - constructed_segments.push_front({M, *closest_intersection_point}); - break; + if (closest_intersection_point) { + constructed_segments.push_front({M, *closest_intersection_point}); + processed_vertices.insert(M); + break; #if 0 Graph2D GGG(bnd); GGG.refine(*GGG.query(*closest_intersection_point, 0.01), *closest_intersection_point); @@ -1217,12 +2079,35 @@ std::list> extend_end_vertices_based_on_input( break; } #endif - } else { - std::cerr << "Warning: no intersection found when extending end vertex, this will likely result in invalid topology" << std::endl; + } else { + + // Loop over boundary segments, and project point onto it, take the closest + K::FT closest_distance = std::numeric_limits::infinity(); + boost::optional> closest_point; + for (auto& poly : outer_perimiter) { + for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { + auto seg = *jt; + auto Pp = seg.supporting_line().projection(M); + if (seg.has_on(Pp)) { + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + } + + if (closest_point) { + constructed_segments.push_front({M, *closest_point}); + processed_vertices.insert(M); + } + } } } } - } #if 0 if (!handled_as_graph_path) { @@ -1267,6 +2152,109 @@ std::list> extend_end_vertices_based_on_input( constructed_segments.push_front({avg, R}); } #endif + } + } + + if (!broke_out) { + break; + } + } + + return constructed_segments; +} + +std::list> +extend_end_vertices_based_on_input_simple( + const Graph2D& G, + const Polygon_list& outer_perimiter, + const K::FT& max_projection_distance) +{ + auto max_intersection_distance = max_projection_distance / 4; + std::list> constructed_segments; + + for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) { + if (it->second.size() == 1) { + auto& M = it->first; + + for (auto& bnd : outer_perimiter) { + // if point M is contained in bnd interior: + // if (!bnd.has_on_unbounded_side(M)) { + if (bnd.has_on_bounded_side(M)) { + auto& incoming = *it->second.begin(); + // create ray incoming -> M + CGAL::Ray_2 ray(incoming, M - incoming); + + // intersect ray with boundary + boost::optional> closest_segment; + boost::optional> closest_intersection_point; + K::FT sq_distance_along_ray = std::numeric_limits::infinity(); + for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) { + const auto& seg = *jt; + auto x = CGAL::intersection(ray, seg); + if (x) { + if (auto* xp = variant_get>(&*x)) { + auto dist = ((*xp) - M).squared_length(); + if (dist < sq_distance_along_ray) { + if (dist < (max_intersection_distance * max_intersection_distance)) { + closest_segment = seg; + closest_intersection_point = *xp; + sq_distance_along_ray = dist; + } else { + } + } + } + } + } + + if (closest_intersection_point) { + constructed_segments.push_front({M, *closest_intersection_point}); + } else { + + // Loop over boundary segments, and project point onto it, take the closest + K::FT closest_distance = std::numeric_limits::infinity(); + boost::optional> closest_point; + for (auto& poly : outer_perimiter) { + for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) { + auto seg = *jt; + auto Pp = seg.supporting_line().projection(M); + if (seg.has_on(Pp)) { + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + } + + if (closest_point) { + constructed_segments.push_front({M, *closest_point}); + } else { + + for (auto& poly : outer_perimiter) { + for (auto it = poly.begin(); it != poly.end(); ++it) { + auto Pp = *it; + auto d = CGAL::squared_distance(Pp, M); + if (d < (max_projection_distance * max_projection_distance)) { + if (d < closest_distance) { + closest_distance = d; + closest_point = Pp; + } + } + } + } + + if (closest_point) { + constructed_segments.push_front({M, *closest_point}); + } else { + std::cout << "Unable to find projection or intersection point for interior boundary (" << M.x() << " " << M.y() << ")" << std::endl; + } + } + } + } + } } } @@ -1355,8 +2343,6 @@ void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D& G, SegmentL } } -#include - class Segment_2_less { public: bool operator()(const Segment_2& a, const Segment_2& b) const { @@ -1367,7 +2353,149 @@ class Segment_2_less { } }; -void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { +std::vector arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) { + + using Walk_pl = CGAL::Arr_walk_along_line_point_location; + Walk_pl walk_pl(right); + + std::set visited_faces_on_right; + + std::vector return_values; + + K::FT max_iou_deviation = 1; + std::array max_deviation_poly_pair; + + for (auto it = left.faces_begin(); it != left.faces_end(); ++it) { + if (!it->is_unbounded()) { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly(it->outer_ccb()); + Polygon_with_holes_2 pwh(polygon_exterior); + for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) { + pwh.add_hole(circ_to_poly(*hit)); + } + // if (!pwh.outer_boundary().is_simple()) { + // throw std::runtime_error("Polygon with holes has a non-simple outer boundary"); + // } + + CGAL::Polygon_triangulation_decomposition_2 decompositor; + std::vector temp; + decompositor(pwh, std::back_inserter(temp)); + + std::set visited_points; + + while (true) { + // select triangle edge that has largest squared edge length times distance from polygon exterior + K::FT max_score = -std::numeric_limits::infinity(); + Point_2 best_point; + for (auto& tri : temp) { + for (size_t i = 0; i < 3; ++i) { + size_t j = (i + 1) % 3; + auto& pi = tri.vertex(i); + auto& pj = tri.vertex(j); + + auto center_point = CGAL::ORIGIN + (((pi - CGAL::ORIGIN) + (pj - CGAL::ORIGIN)) / 2); + + K::FT min_dist = std::numeric_limits::infinity(); + for (auto eit = polygon_exterior.edges_begin(); eit != polygon_exterior.edges_end(); ++eit) { + auto ep = eit->source(); + auto eq = eit->target(); + Segment_2 seg(ep, eq); + auto dist = CGAL::squared_distance(center_point, seg); + if (dist < min_dist) { + min_dist = dist; + } + } + + auto sq_length = CGAL::squared_distance(pi, pj); + + auto score = sq_length * min_dist; + if (score > max_score && visited_points.count(center_point) == 0) { + max_score = score; + best_point = center_point; + } + } + } + + if (max_score == -std::numeric_limits::infinity()) { + // no more points to try + return_values.push_back(0); + break; + } + + visited_points.insert(best_point); + + debug_output.write_point(best_point, "representative_point representative_point_" + std::to_string(std::distance(left.faces_begin(), it))); + + auto res = walk_pl.locate(best_point); + if (auto* v = variant_get(&res)) { + if ((*v)->is_unbounded()) { + // try next point + continue; + } + if (visited_faces_on_right.count(*v) > 0) { + // Maybe we should be more permissive, try some other points etc. + return_values.push_back(0); + std::cout << "Already visited face on right, skipping point\n"; + } else { + // convert arr facet to polygon with holes + auto polygon_exterior = circ_to_poly((*v)->outer_ccb()); + Polygon_with_holes_2 pwh_right(polygon_exterior); + for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) { + pwh_right.add_hole(circ_to_poly(*hit)); + } + // if (!pwh_right.outer_boundary().is_simple()) { + // throw std::runtime_error("Polygon with holes has a non-simple outer boundary"); + // } + + // compute intersection over union of pwh and the original polygon + if (CGAL::do_intersect(pwh, pwh_right)) { + std::vector result; + CGAL::intersection(pwh, pwh_right, std::back_inserter(result)); + typename K::FT intersection_area = 0; + for (auto& r : result) { + auto poly_area = r.outer_boundary().area(); + for (auto& h : r.holes()) { + poly_area -= CGAL::abs(h.area()); + } + intersection_area += poly_area; + } + CGAL::Polygon_with_holes_2 poly12; + CGAL::join(pwh, pwh_right, poly12); + typename K::FT union_area = poly12.outer_boundary().area(); + for (auto& h : poly12.holes()) { + union_area -= CGAL::abs(h.area()); + } + return_values.push_back(intersection_area / union_area); + + auto& v = return_values.back(); + if (v < max_iou_deviation) { + max_iou_deviation = v; + max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()}; + } + } else { + std::cout << "No intersection, skipping point\n"; + return_values.push_back(0); + } + } + visited_faces_on_right.insert(*v); + break; + } else { + // Not in facet on right, retry another point + continue; + } + } + } + } + + if (max_iou_deviation != 1) { + debug_output.write_polygon(max_deviation_poly_pair[0], "max_iou_deviation_left"); + debug_output.write_polygon(max_deviation_poly_pair[1], "max_iou_deviation_right"); + } + + return return_values; +} + +void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double& threshold) { using SK = CGAL::Simple_cartesian; CGAL::Cartesian_converter C{}; @@ -1418,7 +2546,7 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto [dv, dl] = get_dir(s); best = std::min(best, angle(dv)); } - return (best + 0.1) / own_length; + return (best + 0.01) / own_length; }; std::map badnesses; @@ -1426,7 +2554,6 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { badnesses[e] = edge_badness(e); } - double thr; { std::vector tmp; tmp.reserve(badnesses.size()); @@ -1435,12 +2562,12 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { } std::nth_element(tmp.begin(), tmp.begin() + tmp.size() / 2, tmp.end()); double med = tmp[tmp.size() / 2]; - thr = 10.0 * med; + threshold = 4.0 * med; } std::set bad_edges; for (auto& p : badnesses) { - if (p.second > thr) { + if (p.second > threshold) { bad_edges.insert(p.first); } } @@ -1568,7 +2695,46 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { return best_x; }; + auto process_modifications = [&]( + Arrangement_2& arr_, + const std::set>& to_remove_, + const std::vector>& to_insert_) { + for (auto& e : to_remove_) { + bool removed = false; + for (auto he = arr_.edges_begin(); he != arr_.edges_end(); ++he) { + auto a = he->source()->point(); + auto b = he->target()->point(); + if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { + CGAL::remove_edge(arr_, he); + removed = true; + break; + } + } + if (!removed) { + std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; + } + } + + for (auto& pq : to_insert_) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_, Segment_2(pq.first, pq.second)); + } + }; + + size_t path_index = 0; for (auto& path : bad_paths) { + decltype(to_remove) to_remove_this_path; + decltype(to_insert) to_insert_this_path; + + for (size_t i = 0; i < path.size() - 1; ++i) { + auto& a = path[i]; + auto& b = path[i + 1]; + + debug_output.write_segment(a, b, "arr_bad_path path_nr_" + std::to_string(path_index)); + } + auto x = collapse_path(path); if (!x) { // std::cerr << "Unable to collapse path, skipping" << std::endl; @@ -1589,12 +2755,10 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { continue; } - std::cerr << "new_length: " << new_length << " orig_length: " << orig_length << std::endl; - for (size_t i = 0; i < path.size(); ++i) { auto& v = path[i]; if (CGAL::squared_distance(v, *x) < 1.e-5) { - std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl; + // std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl; continue; } } @@ -1604,75 +2768,314 @@ void clean_noisy_paths(Arrangement_2& arr, SegmentLookup& segment_lookup) { auto& b = path[i + 1]; if (a < b) { to_remove.insert({a, b}); + to_remove_this_path.insert({a, b}); } else { to_remove.insert({b, a}); + to_remove_this_path.insert({b, a}); } } auto s = path.front(); auto t = path.back(); if (s != *x) { to_insert.push_back({s, *x}); + to_insert_this_path.push_back({s, *x}); + + debug_output.write_segment(s, *x, "corrected_path path_nr_" + std::to_string(path_index)); } if (t != *x) { to_insert.push_back({t, *x}); + to_insert_this_path.push_back({t, *x}); + + debug_output.write_segment(t, *x, "corrected_path path_nr_" + std::to_string(path_index)); } + + path_index += 1; + +#if 1 + process_modifications(arr, to_remove_this_path, to_insert_this_path); +#else + auto arr_copy = arr; + process_modifications(arr_copy, to_remove_this_path, to_insert_this_path); + auto ious = arrangement_cell_iou(arr, arr_copy); + for (auto& iou : ious) { + std::cerr << " - cell iou: " << CGAL::to_double(iou) << std::endl; + } + std::swap(arr_copy, arr); +#endif } - /* - using Walk_pl = CGAL::Arr_walk_along_line_point_location; - Walk_pl walk_pl(arr); + process_modifications(arr, to_remove, to_insert); +} - for (auto& e : to_remove) { - // debug_output.write_segment(e->source()->point(), e->target()->point(), "arr_bad_remove"); - auto res = walk_pl.locate(e.first); - if (auto* v = boost::get(&res)) { - Arrangement_2::Halfedge_around_vertex_circulator first, curr; - first = curr = (*v)->incident_halfedges(); - size_t i = 0; - std::array pts; - std::array hes; - do { - Arrangement_2::Vertex_const_handle u = curr->source(); - hes[i] = curr; - pts[i++] = u->point(); - } while (++curr != first); +template +void next_circular(typename Vec::const_iterator& it, const Vec& vec) { + std::advance(it, 1); + if (it == vec.end()) { + it = vec.begin(); + } +} +template +void previous_circular(typename Vec::const_iterator& it, const Vec& vec) { + if (it == vec.begin()) { + it = vec.end(); + } + std::advance(it, -1); +} - if ((*v)->point() != e.first) { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; +template +std::size_t circular_distance(typename Vec::const_iterator first, + typename Vec::const_iterator last, + const Vec& vec) { + if (first <= last) { + return static_cast(last - first); + } + return static_cast(vec.end() - first) + static_cast(last - vec.begin()); +} + +template +std::pair +longest_wrapping_true_run(const Vec& v, Pred pred) { + using It = typename Vec::const_iterator; + + const auto n = v.size(); + if (n == 0) { + return {v.end(), v.end()}; + } + + // Find best non-wrapping run + std::size_t best_len = 0; + std::size_t best_start = 0; + + std::size_t curr_len = 0; + std::size_t curr_start = 0; + + for (std::size_t i = 0; i < n; ++i) { + if (pred(v[i])) { + if (curr_len == 0) { + curr_start = i; + } + ++curr_len; + if (curr_len > best_len) { + best_len = curr_len; + best_start = curr_start; } } else { - std::cerr << "Warning: unable to locate vertex for edge removal, skipping" << std::endl; - continue; + curr_len = 0; } } - */ - for (auto& e : to_remove) { - bool removed = false; - for (auto he = arr.edges_begin(); he != arr.edges_end(); ++he) { - auto a = he->source()->point(); - auto b = he->target()->point(); - if ((a == e.first && b == e.second) || (a == e.second && b == e.first)) { - CGAL::remove_edge(arr, he); - removed = true; - break; + // Count leading true + std::size_t leading = 0; + while (leading < n && pred(v[leading])) { + ++leading; + } + + // All true + if (leading == n) { + return {v.begin(), v.end()}; + } + + // Count trailing true + std::size_t trailing = 0; + while (trailing < n && pred(v[n - 1 - trailing])) { + ++trailing; + } + + // Wrapped run = [n - trailing, n) + [0, leading) + const std::size_t wrapped_len = leading + trailing; + + if (wrapped_len > best_len) { + It first = v.begin() + static_cast(n - trailing); + It last = v.begin() + static_cast(leading); + return {first, last}; + } + + It first = v.begin() + static_cast(best_start); + It last = first + static_cast(best_len); + return {first, last}; +} + +void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLookup& segment_lookup, double threshold) { + using SK = CGAL::Simple_cartesian; + CGAL::Cartesian_converter C{}; + + auto other = [](const Segment_2& e, const Point_2& v) { + return (e.source() == v) ? e.target() : e.source(); + }; + + auto edge_badness = [&](const Segment_2& e) -> double { + auto closest = segment_lookup.n_closest_input_segments(e, 2); + if (closest.size() != 2) { + throw std::runtime_error("Unable to locate two nearby edges"); + } + + auto get_dir = [&](const Segment_2& s) { + auto a = C(s.source()); + auto b = C(s.target()); + SK::Vector_2 v = b - a; + double l = std::sqrt(v.squared_length()); + if (l <= 1e-12) { + return std::make_pair(SK::Vector_2(0, 0), 0.); + } + return std::make_pair(v / l, l); + }; + + auto [own_dir, own_length] = get_dir(e); + + auto angle = [&](const SK::Vector_2& ov) { + double d = std::abs(own_dir * ov); + if (d > 1.0) { + d = 1.0; + } + return std::acos(d); + }; + + double best = std::numeric_limits::infinity(); + for (auto& s : closest) { + auto [dv, dl] = get_dir(s); + best = std::min(best, angle(dv)); + } + return (best + 0.01) / own_length; + }; + + size_t facet_index = 0; + for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) { + if (!it->is_unbounded()) { + std::set> to_remove; + std::vector> to_insert; + + std::vector segs; + std::vector vertices; + std::vector halfedges; + + auto circ = it->outer_ccb(); + do { + auto a = circ->source()->point(); + auto b = circ->target()->point(); + segs.emplace_back(a, b); + vertices.push_back(circ->source()); + halfedges.push_back(circ); + ++circ; + } while (circ != it->outer_ccb()); + + std::vector badnesses; + for (auto& e : segs) { + badnesses.push_back(edge_badness(e)); + } + + auto bit = std::min_element(badnesses.begin(), badnesses.end()); + if (*bit > threshold) { + // std::cerr << "All edges are good, skipping" << std::endl; + continue; + } + + auto it_pair = longest_wrapping_true_run(badnesses, [&](double d) { return d > threshold; }); + auto N = circular_distance(it_pair.first, it_pair.second, badnesses); + + if (N == 0) { + // std::cerr << "Unable to find run of bad edges, skipping" << std::endl; + continue; + } + + std::vector> incoming_paths; + + auto jt = it_pair.first; + for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) { + + auto he = halfedges[std::distance(badnesses.cbegin(), jt)]; + to_remove.insert({he->source()->point(), he->target()->point()}); + debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index)); + + Arrangement_2::Vertex_handle v = he->source(); + + // circle around other edges onto v + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = v->incident_halfedges(); + do { + Arrangement_2::Vertex_handle u = curr->source(); + if (curr->face() != it && curr->twin()->face() != it) { + + // loop until we find a 3-degree vertex, or we come back to the start + std::vector path{v->point(), u->point()}; + auto he = curr; + + while (u->degree() == 2 && u != v && path.size() < 10) { + std::vector hes; + + { + Arrangement_2::Halfedge_around_vertex_circulator first, curr; + first = curr = u->incident_halfedges(); + do { + hes.push_back(curr); + curr++; + } while (curr != first); + } + + auto next_he = hes.front() != he && hes.front() != he->twin() ? hes.front() : hes.back(); + auto next_v = next_he->target() != u ? next_he->target() : next_he->source(); + + path.push_back(next_v->point()); + u = next_v; + } + incoming_paths.push_back(std::move(path)); + } + } while (++curr != first); + } + + const std::size_t start = + static_cast(std::distance(badnesses.cbegin(), it_pair.first)); + + auto n = badnesses.size(); + + auto wrap = [n](std::ptrdiff_t i) -> std::size_t { + i %= static_cast(n); + if (i < 0) { + i += static_cast(n); + } + return static_cast(i); + }; + + const std::size_t ib = start; + const std::size_t ia = wrap(static_cast(start) - 1); + const std::size_t ic = wrap(static_cast(start + N)); + const std::size_t id = wrap(static_cast(start + N + 1)); + + auto a = vertices.begin() + static_cast(ia); + auto b = vertices.begin() + static_cast(ib); + auto c = vertices.begin() + static_cast(ic); + auto d = vertices.begin() + static_cast(id); + + CGAL::Ray_2 r1((*a)->point(), (*b)->point()); + CGAL::Ray_2 r2((*d)->point(), (*c)->point()); + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } else { + CGAL::Line_2 r1((*a)->point(), (*b)->point()); + CGAL::Line_2 r2((*d)->point(), (*c)->point()); + + auto x = CGAL::intersection(r1, r2); + if (x) { + if (auto* xp = variant_get>(&*x)) { + to_insert.emplace_back((*b)->point(), *xp); + to_insert.emplace_back((*c)->point(), *xp); + + debug_output.write_segment((*b)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + debug_output.write_segment((*c)->point(), *xp, "corrected_bound facet_" + std::to_string(facet_index)); + } + } } } - if (!removed) { - std::cerr << "Warning: unable to locate edge for removal, skipping" << std::endl; - } } - - for (auto& pq : to_insert) { - if (pq.first == pq.second) { - continue; - } - CGAL::insert(arr, Segment_2(pq.first, pq.second)); - // debug_output.write_segment(pq.first, pq.second, "arr_bad_insert"); - } - } void remove_colinear_vertices(Arrangement_2& arr) { @@ -1725,20 +3128,31 @@ class timer { public: class entry { public: + entry() {} + entry(std::map::const_iterator start_it) : start_it(start_it) {} + void stop() { - auto end = std::chrono::high_resolution_clock::now(); - auto duration = std::chrono::duration(end - start_it->second).count(); - std::cerr << "Timing for " << start_it->first << ": " << duration << " ms" << std::endl; + if (start_it) { + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration(end - start_it.value()->second).count(); + std::cerr << "Timing for " << start_it.value()->first << ": " << duration << " ms" << std::endl; + } } private: - std::map::const_iterator start_it; + std::optional::const_iterator> start_it; }; + timer(bool enabled = true) : enabled_(enabled) {} + entry start(const std::string& name) { - return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + if (enabled_) { + return entry(timings_.insert({name, std::chrono::high_resolution_clock::now()}).first); + } else { + return entry(); + } } private: @@ -1746,27 +3160,43 @@ class timer { std::string, std::chrono::high_resolution_clock::time_point> timings_; + + bool enabled_; }; -void arrange_cgal_polygons(const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { +size_t delete_same_facet_edge_pairs(Arrangement_2& arr) { + size_t n_deleted = 0; + for (auto it = arr.edges_begin(); it != arr.edges_end();) { + decltype(it) current = it++; + if (current->face() == current->twin()->face()) { + arr.remove_edge(current); + n_deleted++; + } + } + return n_deleted; +} + +void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector& input_polygons_, std::vector& output_polygons, double polygon_offset_distance = -1.) { + static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1; // even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied // no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play? static const double OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT = 1.e-5; -#ifdef SVGFILL_DEBUG - auto t = std::time(nullptr); - auto tm = *std::localtime(&t); + DebugWriter debug_output; + if (settings.debug_output) { + auto t = std::time(nullptr); + auto tm = *std::localtime(&t); - std::ostringstream oss; - oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); - auto now = oss.str(); - DebugWriter debug_output(true, now); -#else - DebugWriter debug_output(false, ""); -#endif + std::ostringstream oss; + oss << std::put_time(&tm, "arrangement_%Y%m%d%H%M%S"); + auto now = oss.str(); + debug_output = DebugWriter(true, now); + } else { + debug_output = DebugWriter(false, ""); + } - timer timer; + timer timer(settings.debug_output); auto t0 = timer.start("input"); @@ -1794,7 +3224,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("overlap elimination"); - eliminate_overlaps(OVERLAP_RESOLUTION_DISTANCE, input_polygons); + eliminate_overlaps(debug_output, OVERLAP_RESOLUTION_DISTANCE, input_polygons); t0.stop(); @@ -1813,79 +3243,79 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_polygons(input_polygons, "processed_input"); -#if 1 - t0 = timer.start("outer perimeter"); - - // Find the outer perimeter using offset - union - negative offset - std::vector offset_polygons; - for (auto& r : input_polygons) { - auto R = r; - if (!R.is_counterclockwise_oriented()) { - R.reverse_orientation(); - } - - // Overlap removal can also result in close points causing problems when converted into non-exact nt - remove_close_points(R); - - auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); - for (auto& p : ps) { - if (!p.is_simple()) { - throw std::runtime_error("Complex polygon originated from offset"); - } - } - offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); - } - - debug_output.write_polygons(offset_polygons, "offset_input"); - - // Perform Boolean union on the offset polygons - std::vector unioned_polygons; - CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); - - if (unioned_polygons.size() > 1) { - // @todo this is currently one of the major limitations in the code that still can be eliminated - // by grouping the input polygons by their perimiter polygon in unioned_polygons - std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); - } - - debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); - - Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); - remove_close_points(fused_removed_close_points, 1.e-4); - - // Apply negative offset to get the outer perimeter polygon - auto outer_perimiter = create_and_convert_offset_polygon( - // Because polygon_offset is inexact, make sure our inset distance is slightly larger - // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), - - // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter - -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, - fused_removed_close_points); - - debug_output.write_polygons(outer_perimiter, "outer_perimiter"); -#else - std::map> neighbour_map; - build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); - - auto start_vertex = neighbour_map.rbegin()->first; - auto next_vertex = neighbour_map.rbegin()->second.front(); - - std::vector cycle = {start_vertex, next_vertex}; - while (cycle.back() != cycle.front()) { - const auto& incoming_from = *(cycle.rbegin() + 1); - const auto& nb = neighbour_map[cycle.back()]; - auto it = std::find(nb.begin(), nb.end(), incoming_from); - // cycle it -1 around nb - if (it == nb.begin()) { - it == nb.end() - 1; - } else { - --it; - } - cycle.push_back(*it); - } std::vector outer_perimiter; - outer_perimiter.emplace_back(cycle.begin(), cycle.end()); -#endif + if (settings.outer_perimiter_algo == 0) { + t0 = timer.start("outer perimeter"); + + // Find the outer perimeter using offset - union - negative offset + std::vector offset_polygons; + for (auto& r : input_polygons) { + auto R = r; + if (!R.is_counterclockwise_oriented()) { + R.reverse_orientation(); + } + + // Overlap removal can also result in close points causing problems when converted into non-exact nt + remove_close_points(R); + + auto ps = create_and_convert_offset_polygon(polygon_offset_distance, R); + for (auto& p : ps) { + if (!p.is_simple()) { + throw std::runtime_error("Complex polygon originated from offset"); + } + } + offset_polygons.insert(offset_polygons.end(), ps.begin(), ps.end()); + } + + debug_output.write_polygons(offset_polygons, "offset_input"); + + // Perform Boolean union on the offset polygons + std::vector unioned_polygons; + CGAL::join(offset_polygons.begin(), offset_polygons.end(), std::back_inserter(unioned_polygons)); + + if (unioned_polygons.size() > 1) { + // @todo this is currently one of the major limitations in the code that still can be eliminated + // by grouping the input polygons by their perimiter polygon in unioned_polygons + std::sort(unioned_polygons.begin(), unioned_polygons.end(), [](auto& p, auto& q) { return p.outer_boundary().area() > q.outer_boundary().area(); }); + } + + debug_output.write_polygon(unioned_polygons.front().outer_boundary(), "offset_joined"); + + Polygon_2 fused_removed_close_points = unioned_polygons.front().outer_boundary(); + remove_close_points(fused_removed_close_points, 1.e-4); + + // Apply negative offset to get the outer perimeter polygon + outer_perimiter = create_and_convert_offset_polygon( + // Because polygon_offset is inexact, make sure our inset distance is slightly larger + // std::nexttoward(-polygon_offset_distance, -std::numeric_limits::infinity()), + + // 1.e-8 even was too little and still resulted in slivers of triangle around the perimeter + -polygon_offset_distance - OUTER_PERIMITER_ADDITIONAL_INSET_AMOUNT, + fused_removed_close_points); + + debug_output.write_polygons(outer_perimiter, "outer_perimiter"); + } else { + std::map> neighbour_map; + build_radial_neighbour_map(input_polygons, polygon_offset_distance, neighbour_map); + + auto start_vertex = neighbour_map.rbegin()->first; + auto next_vertex = neighbour_map.rbegin()->second.front(); + + std::vector cycle = {start_vertex, next_vertex}; + while (cycle.back() != cycle.front()) { + const auto& incoming_from = *(cycle.rbegin() + 1); + const auto& nb = neighbour_map[cycle.back()]; + auto it = std::find(nb.begin(), nb.end(), incoming_from); + // cycle it -1 around nb + if (it == nb.begin()) { + it = nb.end() - 1; + } else { + --it; + } + cycle.push_back(*it); + } + outer_perimiter.emplace_back(cycle.begin(), cycle.end()); + } t0.stop(); t0 = timer.start("corridor creation"); @@ -1909,11 +3339,17 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("corridor triangulation"); + SegmentLookup segment_lookup(input_polygons); + // subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network + // We store correspondence of subdivision points to input polygons when subdividing so that we do not need to query, which is expensive, when building the line graph later on. + std::map point_lookup; + + auto subdivision_length = polygon_offset_distance / settings.subdivision_factor; + for (auto& pwh : difference_result) { - difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 8., pwh)); - // difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh)); + difference_result_subdivided.push_back(subdivide_polygon_on_same_input(segment_lookup, subdivision_length, pwh, point_lookup)); } debug_output.write_polygons(difference_result_subdivided, "corridor_subdivided"); @@ -1938,9 +3374,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_polygons(triangular_polygons, "triangulated_corridor"); - SegmentLookup segment_lookup(input_polygons); - - auto [line_graph, midpoint_to_segment, segment_to_input_facet] = build_line_graph(input_polygons, segment_lookup, triangular_polygons); + auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, point_lookup, triangular_polygons); for (auto& p : line_graph) { for (auto& q : p.second) { debug_output.write_segment(p.first, q, "network_1"); @@ -1950,38 +3384,129 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v t0.stop(); t0 = timer.start("center line cleaning"); - - auto triangles = find_triangles(line_graph); - // For every triangle found in the network we eliminate one edge to break the cycle - // The edge we eliminate is the edge with the greatest angle with any of it's neighbours - auto eliminated_segments = eliminate_triangles(line_graph); + Graph2D G; - Graph2D G2(line_graph); - for (auto& e : eliminated_segments) { - debug_output.write_segment(e.first, e.second, "eliminated"); - G2.remove_edge(e.first, e.second); + { + // this is applied for both algos + auto eliminated_segments = eliminate_triangles(line_graph); + for (auto e : eliminated_segments) { + debug_output.write_segment(e.first, e.second, "eliminated"); + for (int i = 0; i < 2; ++i) { + auto it = line_graph.find(e.first); + if (it == line_graph.end()) { + std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl; + continue; + } + auto& neighbours = it->second; + neighbours.erase(std::remove(neighbours.begin(), neighbours.end(), e.second), neighbours.end()); + if (neighbours.empty()) { + line_graph.erase(it); + } + std::swap(e.first, e.second); + } + } } - auto G = G2.weld_vertices(); + Graph2D G_orig(line_graph); - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_2"); - } + auto apply_line_cleaning_algo_1 = [&]() { + Graph2D G2(line_graph); + G = G2.weld_vertices(); + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_2"); + } + eliminate_colinear_vertices(G); + edge_slide(G); + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_3"); + } + }; - eliminate_colinear_vertices(G); - - edge_slide(G); - - for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { - debug_output.write_segment(it->first, it->second, "network_3"); + if (settings.line_cleaning_algo == 0) { + G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length, subdivision_length * 4); + Arrangement_2 arr; + G.to_arrangement(arr); + Graph2D G2; + G2.from_arrangement(arr); + eliminate_colinear_vertices(G2); + G = G2; + for (auto it = G.edges_begin(); it != G.edges_end(); ++it) { + debug_output.write_segment(it->first, it->second, "network_2"); + } + } else { + apply_line_cleaning_algo_1(); } t0.stop(); t0 = timer.start("topology"); - auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup); + std::list> segments, segments1, segments2; + bool fallback_to_line_cleaning_algo_1 = false; + + if (settings.line_cleaning_algo == 0) { + segments1 = extend_end_vertices_based_on_input_simple(G, outer_perimiter, subdivision_length * 16); + segments2 = extend_end_vertices_based_on_input_simple(G_orig, outer_perimiter, subdivision_length * 16); + + Arrangement_2 arr_clean; + G.to_arrangement(arr_clean); + for (auto& pq : segments1) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_clean, Segment_2(pq.first, pq.second)); + } + + Arrangement_2 arr_orig; + G_orig.to_arrangement(arr_orig); + for (auto& pq : segments2) { + if (pq.first == pq.second) { + continue; + } + CGAL::insert(arr_orig, Segment_2(pq.first, pq.second)); + } + + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr_orig, Segment_2(source, target)); + CGAL::insert(arr_clean, Segment_2(source, target)); + } + } + + delete_same_facet_edge_pairs(arr_clean); + delete_same_facet_edge_pairs(arr_orig); + + debug_output.write_polygons(arr_clean, "iou_left"); + debug_output.write_polygons(arr_orig, "iou_right"); + + auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig); + /* + for (auto& iou : ious) { + std::cout << " " << CGAL::to_double(iou - 1); + } + std::cout << std::endl; + */ + + auto it = std::min_element(ious.begin(), ious.end()); + + if (it != ious.end() && (*it < 0.45)) { + std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl; + fallback_to_line_cleaning_algo_1 = true; + apply_line_cleaning_algo_1(); + } else { + segments = segments1; + } + } + + if (settings.line_cleaning_algo != 0 || fallback_to_line_cleaning_algo_1) { + segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4); + } // Now plot the edges on an arrangement in order to find planar cycles // and merge the corridor-halves with their neighbouring input polygon @@ -1997,41 +3522,33 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v debug_output.write_segment(pq.first, pq.second, "extended_segments"); } -#if 0 - // Write input polygons to arrangement_2 - // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter - for (auto& poly : input_polygons) { - for (size_t i = 0; i != poly.size(); ++i) { - auto j = (i + 1) % poly.size(); - if (poly.vertex(i) == poly.vertex(j)) { - continue; + if (settings.topology_reconstruction_algo != 0) { + // Write input polygons to arrangement_2 + // We no longer do this because we add the outer perimiter now, subdivided by the corridor network which is extended and intersected with the outer perimiter + for (auto& poly : input_polygons) { + for (size_t i = 0; i != poly.size(); ++i) { + auto j = (i + 1) % poly.size(); + if (poly.vertex(i) == poly.vertex(j)) { + continue; + } + CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); + } + } + } else { + // Write outer perimeter to arrangement_2 + for (auto& p : outer_perimiter) { + for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { + auto source = it->source(); + auto target = it->target(); + if (source == target) { + continue; + } + CGAL::insert(arr, Segment_2(source, target)); } - CGAL::insert(arr, Segment_2(poly.vertex(i), poly.vertex(j))); } } -#else - // Write outer perimeter to arrangement_2 - for (auto& p : outer_perimiter) { - for (auto it = p.edges_begin(); it != p.edges_end(); ++it) { - auto source = it->source(); - auto target = it->target(); - if (source == target) { - continue; - } - CGAL::insert(arr, Segment_2(source, target)); - } - } -#endif - // Just for the automatic numbering, create a full vector - std::vector temp; - for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) { - if (it->is_unbounded()) { - continue; - } - temp.push_back(circ_to_poly(it->outer_ccb())); - } - debug_output.write_polygons(temp, "arr_faces"); + debug_output.write_polygons(arr, "arr_faces"); /* { @@ -2047,12 +3564,17 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v // corridor network we know it needs to be joined with an input polygon. In that // case the edges need to be eliminated that correspond to original geometry. -#if 0 - fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); -#else - remove_colinear_vertices(arr); - clean_noisy_paths(arr, segment_lookup); -#endif + if (settings.topology_reconstruction_algo != 0) { + fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output); + } + + if (settings.perform_cleanup && settings.line_cleaning_algo != 0) { + remove_colinear_vertices(arr); + double threshold; + clean_noisy_paths(debug_output, arr, segment_lookup, threshold); + remove_colinear_vertices(arr); + // clean_noisy_bounds(debug_output, arr, segment_lookup, threshold); + } t0.stop(); @@ -2068,8 +3590,7 @@ void arrange_cgal_polygons(const std::vector& input_polygons_, std::v #ifndef SVGFILL_MAIN -bool svgfill::arrange_polygons(const std::vector& polygons, std::vector& arranged) -{ +bool svgfill::arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged) { std::vector cgal_polygons, cgal_polygons_out; std::transform(polygons.begin(), polygons.end(), std::back_inserter(cgal_polygons), [](auto& poly) { Polygon_2 result; @@ -2078,7 +3599,7 @@ bool svgfill::arrange_polygons(const std::vector& polygons, }); return result; }); - arrange_cgal_polygons(cgal_polygons, cgal_polygons_out); + arrange_cgal_polygons(settings, cgal_polygons, cgal_polygons_out); std::transform(cgal_polygons_out.begin(), cgal_polygons_out.end(), std::back_inserter(arranged), [](auto& poly) { svgfill::polygon_2 result; std::transform(poly.begin(), poly.end(), std::back_inserter(result.boundary), [](auto& pt) { @@ -2128,7 +3649,7 @@ int main(int argc, char** argv) { input_polygons.back().push_back(CGAL::Point_2(x, y)); } } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); break; } return 0; @@ -2141,7 +3662,7 @@ int main(int argc, char** argv) { input_polygons = { rect1, rect2, rect3, rect4, rect5 }; } - arrange_cgal_polygons(input_polygons, output); + arrange_cgal_polygons(arrange_polygon_settings{}, input_polygons, output); return 0; } diff --git a/src/svgfill/src/graph_2d.h b/src/svgfill/src/graph_2d.h index da2b5014ec..76d8200d4c 100644 --- a/src/svgfill/src/graph_2d.h +++ b/src/svgfill/src/graph_2d.h @@ -178,6 +178,9 @@ public: std::vector> segments; for (const auto& p : adjacency_list) { for (const auto& q : p.second) { + if (p.first == q) { + return false; + } if (p.first < q) { segments.emplace_back(p.first, q); } @@ -198,7 +201,7 @@ public: any = true; } }); - return any; + return !any; } // Eliminates a vertex with exactly two neighbors by connecting its neighbors @@ -338,11 +341,27 @@ public: template void to_arrangement(T& arr) { - for (auto it = edges_begin(); it != edges_end(); ++it) { - if (it->first == it->second) { - continue; + if (is_valid() && arr.is_empty()) { + std::vector> edges; + + for (auto it = edges_begin(); it != edges_end(); ++it) { + edges.emplace_back(it->first, it->second); } - CGAL::insert(arr, CGAL::Segment_2(it->first, it->second)); + CGAL::insert_non_intersecting_curves(arr, edges.begin(), edges.end()); + } else { + for (auto it = edges_begin(); it != edges_end(); ++it) { + if (it->first == it->second) { + continue; + } + CGAL::insert(arr, CGAL::Segment_2(it->first, it->second)); + } + } + } + + template + void from_arrangement(T& arr) { + for (auto it = arr.edges_begin(); it != arr.edges_end(); ++it) { + insert(it->source()->point(), it->target()->point()); } } diff --git a/src/svgfill/src/svgfill.cpp b/src/svgfill/src/svgfill.cpp index 8a2a1bb008..cf45881c93 100644 --- a/src/svgfill/src/svgfill.cpp +++ b/src/svgfill/src/svgfill.cpp @@ -483,6 +483,18 @@ public: return ps; } + size_t delete_same_facet_edge_pairs() { + size_t n_deleted = 0; + for (auto it = arr.edges_begin(); it != arr.edges_end();) { + decltype(it) current = it++; + if (current->face() == current->twin()->face()) { + arr.remove_edge(current); + n_deleted++; + } + } + return n_deleted; + } + void merge(const std::vector& edge_indices) { if (edge_indices.empty()) { return; diff --git a/src/svgfill/src/svgfill.h b/src/svgfill/src/svgfill.h index 396fc9c924..2588636a8d 100644 --- a/src/svgfill/src/svgfill.h +++ b/src/svgfill/src/svgfill.h @@ -67,6 +67,7 @@ namespace svgfill { virtual std::vector get_face_pairs() = 0; virtual size_t num_edges() = 0; virtual size_t num_faces() = 0; + virtual size_t delete_same_facet_edge_pairs() = 0; }; class SVGFILL_API context { @@ -101,6 +102,7 @@ namespace svgfill { void write(std::vector>&); size_t num_edges() { return arr_->num_edges(); } size_t num_faces() { return arr_->num_faces(); } + size_t delete_same_facet_edge_pairs() { return arr_->delete_same_facet_edge_pairs(); } ~context() { delete arr_; @@ -113,7 +115,25 @@ namespace svgfill { SVGFILL_API std::string polygons_to_svg(const std::vector>& polygons, bool random_color=false); SVGFILL_API std::string polygons_to_svg(const std::vector& polygons, bool random_color = false); SVGFILL_API bool svg_to_polygons(const std::string& data, const boost::optional& class_name, std::vector& polygons); - SVGFILL_API bool arrange_polygons(const std::vector& polygons, std::vector& arranged); -} + + struct SVGFILL_API arrange_polygon_settings { + bool debug_output = false; + // -1: compute from average edge length + double polygon_offset_distance = -1.; + // 0: use offset - union - negative offset to find the outer perimeter + // 1: radial walk along vertices; exact, but can only reuse vertices, not create new positions by means of intersections + int outer_perimiter_algo = 0; + // 0: outer perimiter and corridor center lines + // 1: input polygons, corridor center lines and segments connecting corridor center lines to input polygons + int topology_reconstruction_algo = 0; + // 0: join segment runs + // 1: local badness reduction + int line_cleaning_algo = 0; + bool perform_cleanup = true; + double subdivision_factor = 16.; + }; + + SVGFILL_API bool arrange_polygons(arrange_polygon_settings settings, const std::vector& polygons, std::vector& arranged); + } #endif diff --git a/win/build-all-win.py b/win/build-all-win.py index 24c3e7dc8b..ded9a36bb6 100644 --- a/win/build-all-win.py +++ b/win/build-all-win.py @@ -114,7 +114,7 @@ def archive_python_packages() -> None: deps_path = REPO_PATH / "_deps" python_versions: list[str] = [] for d in deps_path.iterdir(): - if d.is_dir() and d.name.startswith("python."): + if d.is_dir() and (d.name.startswith("python.") or d.name.startswith("pythonarm64.")): python_version = d.name.partition(".")[2] python_path = d / "tools" archive_python_package(python_version, python_path)
    {entity.class} {entity.predefined_type || '-'}
    ... {specReport.applicable_entities.length - 10} more failing elements not shown ...... {applicableEntities.length - 10} more failing elements not shown ...