Compare commits

..

5 Commits

Author SHA1 Message Date
Bruno Postle 07b53f38b4 regenerate_wall_representation: document BBIM_Boolean preservation requirement 2026-03-23 22:32:49 +00:00
Bruno Postle e80fd56d3f Doc clarification for api.geometry.add_wall_representation clippings normal 2026-03-23 22:32:27 +00:00
Bruno Postle 0f0ced593b Doc clarification for api.feature.remove_feature
Generated with the assistance of an AI coding tool.
2026-03-23 22:32:09 +00:00
Bruno Postle 05bd7df68f Doc clarification for api.geometry.edit_object_placement
Generated with the assistance of an AI coding tool.
2026-03-23 22:32:08 +00:00
Bruno Postle ee4681dc4c Doc clarification for api.sequence.assign_process
Generated with the assistance of an AI coding tool.
2026-03-23 22:32:08 +00:00
223 changed files with 1041 additions and 17634 deletions
@@ -1,95 +0,0 @@
#!/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()
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
python ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: mac-${{ matrix.arch }}
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
python ../IfcOpenShell/nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}
+5 -11
View File
@@ -9,13 +9,6 @@ jobs:
container: rockylinux:9
steps:
- name: Set up uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.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
@@ -24,6 +17,7 @@ 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
@@ -51,10 +45,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py unpack
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -62,7 +56,7 @@ jobs:
shell: bash
run: |
set -o pipefail
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
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
- name: Upload Build Logs
if: always()
@@ -77,7 +71,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py pack
python3 ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
+5 -11
View File
@@ -9,13 +9,6 @@ jobs:
container: arm64v8/rockylinux:9
steps:
- name: Set up uv
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.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
@@ -24,6 +17,7 @@ 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
@@ -51,10 +45,10 @@ jobs:
- name: Unpack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py unpack
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
@@ -62,7 +56,7 @@ jobs:
shell: bash
run: |
set -o pipefail
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
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
- name: Upload Build Logs
if: always()
@@ -77,7 +71,7 @@ jobs:
- name: Pack Dependencies
run: |
cd build
uv run ../nix/cache_dependencies.py pack
python3 ../nix/cache_dependencies.py pack
- name: Commit and Push Changes to Build Repository
run: |
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
@@ -1,4 +1,4 @@
name: ci-lint
name: ci-black-formatting
on:
push:
@@ -30,7 +30,6 @@ 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
@@ -58,13 +57,6 @@ 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: |
@@ -95,7 +87,8 @@ jobs:
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
}
run_check poe ruff
run_check poe ruff-main
run_check poe ruff-old
exit $ERROR
continue-on-error: true
@@ -112,7 +105,4 @@ 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
+2 -2
View File
@@ -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.1/blender-5.1.0-linux-x64.tar.xz
wget -q -O blender.tar.xz https://download.blender.org/release/Blender5.0/blender-5.0.1-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_py313*-linux-x64.zip)"
bonsai_zip="$(pwd)/$(ls bonsai_unstable_repo/bonsai_py311*-linux-x64.zip)"
# Install Bonsai.
blender --command extension install-file -r user_default -e $bonsai_zip
+1 -6
View File
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py311, py312, py313]
pyver: [py311, py312]
config:
- {
name: "Windows Build",
@@ -42,11 +42,6 @@ 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
-35
View File
@@ -1,35 +0,0 @@
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
-36
View File
@@ -1,36 +0,0 @@
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
@@ -24,7 +24,7 @@ jobs:
if: |
github.repository == 'IfcOpenShell/IfcOpenShell'
steps:
- uses: mamba-org/setup-micromamba@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
@@ -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@v3 # https://github.com/mamba-org/setup-micromamba
- uses: mamba-org/setup-micromamba@v2 # https://github.com/mamba-org/setup-micromamba
with:
environment-name: test-env
create-args: >-
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
-
name: Build ifcopenshell
-35
View File
@@ -1,35 +0,0 @@
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
@@ -1,46 +0,0 @@
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}"
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
libhdf5-dev libcgal-dev libeigen3-dev
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.21
with:
key: ubuntu-22.04-${{ runner.arch }}
+36
View File
@@ -0,0 +1,36 @@
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
-65
View File
@@ -1,65 +0,0 @@
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
@@ -1,16 +0,0 @@
name: Publish Bonsai Releases
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv run .github/scripts/publish-bonsai-releases.py
env:
BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}
+17 -24
View File
@@ -1,4 +1,4 @@
name: Deploy Pyodide Demo App to static page repo
name: Deploy Pyodide Demo App to GitHub Pages
permissions:
id-token: write
@@ -11,7 +11,6 @@ on:
- '.github/workflows/publish-pyodide-demo-app.yml'
branches:
- v0.8.0
workflow_dispatch:
jobs:
activate:
@@ -31,27 +30,21 @@ jobs:
with:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Upload static files as artifact
id: deployment
uses: actions/upload-pages-artifact@v4
with:
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'
path: src/pyodide/demo-app/
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
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
-25
View File
@@ -100,31 +100,6 @@ AI-generated, identify them.
- Otherwise, add a blank line after the subject followed by a short explanation
of a few lines in the body.
### AI Effort Indicator (Optional but Recommended)
For commits generated with the assistance of an AI tool, include an **effort
estimate** in the commit message body indicating how difficult the agent
assessed the task to be.
This should be a number from **1 (trivial)** to **10 (very difficult or uncertain)**.
This signal can help reviewers identify which changes may require closer
inspection, especially where the agent encountered ambiguity or complexity.
Example:
```
Fix off-by-one error in element iteration
The loop termination condition was incorrect when processing
IfcRelAggregates relationships.
Generated with the assistance of an AI coding tool.
AI effort: 7/10
```
Note: This value is subjective and should not be treated as a guarantee of
correctness. High-effort changes may warrant additional scrutiny, but
low-effort changes can still contain errors.
## Code Style
### Python
+2 -5
View File
@@ -50,14 +50,11 @@ 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)
| [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) |
| [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) |
| [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/) |
| [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)
| [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)
| [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:
+1 -1
View File
@@ -1 +1 @@
0.8.6
0.8.5
+10 -15
View File
@@ -1,6 +1,4 @@
#!/usr/bin/python
# /// script
# ///
###############################################################################
# #
# This file is part of IfcOpenShell. #
@@ -128,7 +126,13 @@ from collections.abc import Generator, Sequence
from pathlib import Path
from urllib.request import urlretrieve
from typing import Literal, Union
try:
from typing import Literal, Union
except:
# python 3.6 compatibility for rocky 8
from typing import Union
from typing_extensions import Literal
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
@@ -1090,19 +1094,10 @@ 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_install = INSTALL_DIR / f"python-{PYTHON_VERSION}"
python_bin = python_install / "bin" / "python3"
python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3"
# `_ssl` module is present -> we will be able to install `numpy` later
# to verify IfcOpenShell installation
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
run([str(python_bin), "-c", "import _ssl"])
if MAC_CROSS_COMPILE_INTEL:
assert original_path
@@ -1520,7 +1515,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 wheel
# Empty pyproject so it's contents won't affect the resulting wheelthe the
# otherwise the wheel will use version and dependencies from toml, not setup.py.
(REPO_PATH / "pyproject.toml").write_text("")
-2
View File
@@ -1,5 +1,3 @@
# /// script
# ///
"""
Cache built dependencies for builds.
+10 -3
View File
@@ -14,11 +14,18 @@ source .venv/bin/activate
uv pip install pyodide-build
# `uv run` is required, so xbuildenv would skip using `pip`.
uv run pyodide xbuildenv install
uv run pyodide xbuildenv install-emscripten
EMSDK_ROOT=$(pyodide config get emscripten_dir)
source ${EMSDK_ROOT}/emsdk_env.sh
# 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
which emcc
popd
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
-232
View File
@@ -1,232 +0,0 @@
#
# /// 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()
+1 -39
View File
@@ -2,16 +2,12 @@
# 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
# 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
REPO_FOLDER = Path(__file__).parent
def get_version() -> str:
@@ -29,39 +25,6 @@ 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(),
@@ -81,5 +44,4 @@ setup(
},
# Has to provide extension to get the correct wheel suffix.
ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])],
cmdclass={"build_ext": UnixBuildExt},
)
+9 -8
View File
@@ -3,9 +3,8 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.10",
"ruff==0.15.7",
"poethepoet",
"ty==0.0.29",
"gersemi==0.26.1",
]
@@ -29,9 +28,6 @@ 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
@@ -88,6 +84,7 @@ all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
byte-string-type-annotation = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
@@ -99,6 +96,7 @@ empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
fstring-type-annotation = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
@@ -215,7 +213,10 @@ exclude = [
[tool.poe.tasks]
ruff = "ruff check"
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"]
black = "black ."
@@ -223,7 +224,7 @@ 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.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
ty-venv-bonsai.sequence = [
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
@@ -235,7 +236,7 @@ ty-venv-ios.sequence = [
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
]
format.sequence = ["black", "ruff"]
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
+5 -7
View File
@@ -17,8 +17,8 @@
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
SHELL := sh
PYTHON:=python3
PIP:=pip3
PYTHON:=python3.11
PIP:=pip3.11
PATCH:=patch
SED:=sed -i
VENV_ACTIVATE:=bin/activate
@@ -48,7 +48,6 @@ 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
@@ -64,7 +63,6 @@ PYNUMBER:=3$(PYMINOR)
PYPI_VERSION:=3.$(PYMINOR)
endif # def PYVERSION
IFCMERGE_VERSION:=2026-04-07
ifdef PLATFORM
SUPPORTED_PLATFORMS := linux macos macosm1 win
@@ -241,9 +239,10 @@ endif
# required for three-way git merging
ifeq ($(PLATFORM), win)
cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(IFCMERGE_VERSION)/ifcmerge.exe
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
else
cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/$(IFCMERGE_VERSION)/ifcmerge && chmod +x ifcmerge
cd build/bonsai/libs/bin && wget https://raw.githubusercontent.com/brunopostle/ifcmerge/main/ifcmerge && chmod +x ifcmerge
endif
# Generate translations module for Bonsai build
@@ -262,7 +261,6 @@ 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
-13
View File
@@ -43,7 +43,6 @@ 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]:
@@ -61,15 +60,6 @@ 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] = {}
@@ -135,7 +125,6 @@ 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,
}
@@ -262,12 +251,10 @@ 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
+2 -2
View File
@@ -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
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+1 -20
View File
@@ -73,22 +73,6 @@ 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)
@@ -105,15 +89,12 @@ 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, update=update_relating_object
)
relating_object: PointerProperty(name="Relating Whole", type=bpy.types.Object, poll=poll_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:
@@ -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()
ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""Element IFC class."""
attribute_name: bpy.props.StringProperty()
attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""IFC class attribute name."""
data_path: bpy.props.StringProperty()
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""Full data path"""
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"})
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
"""IFC id to preselect in the popup."""
if TYPE_CHECKING:
@@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup):
class ExplorerEntity(PropertyGroup):
ifc_definition_id: bpy.props.IntProperty()
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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(
is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration]
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(
ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration]
name="IFC Class To Search",
items=get_ifc_class,
update=update_ifc_class,
)
entities: CollectionProperty(type=ExplorerEntity)
active_entity_index: IntProperty()
editing_entity_id: IntProperty()
entity_attributes: CollectionProperty(type=Attribute)
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]
if TYPE_CHECKING:
is_loaded: bool
@@ -377,8 +377,6 @@ 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"}
@@ -394,8 +392,6 @@ 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"}
@@ -415,8 +411,6 @@ 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"}
@@ -21,7 +21,6 @@ from typing import TYPE_CHECKING, Union
import bpy
from bpy.props import (
BoolProperty,
EnumProperty,
PointerProperty,
)
from bpy.types import PropertyGroup
@@ -51,43 +50,12 @@ 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
@@ -95,8 +63,6 @@ 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):
@@ -77,10 +77,6 @@ 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")
@@ -88,8 +84,6 @@ 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)
@@ -116,11 +110,6 @@ 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)
+10 -4
View File
@@ -201,10 +201,16 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper):
"ALT+click to run a quick clash without selecting a file to save."
)
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(
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]
options={"SKIP_SAVE"},
)
+4 -4
View File
@@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty
class ClashSource(PropertyGroup):
name: StringProperty(
name: StringProperty( # pyright: ignore[reportRedeclaration]
name="File",
description="Absolute filepath to existing .ifc file to use as a clash source.",
)
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups")
mode: EnumProperty(
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration]
mode: EnumProperty( # pyright: ignore[reportRedeclaration]
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(
clash_type: EnumProperty( # pyright: ignore[reportRedeclaration]
name="Clash Type",
items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS),
)
@@ -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()
cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
cost_schedule: int
@@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator):
)
bl_options = {"REGISTER"}
geometry_library: bpy.props.EnumProperty(
geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
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(
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Log Level",
items=[(i, i, "") for i in get_args(LogLevelType)],
default="WARNING",
@@ -246,17 +246,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(
print_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Print All",
default=False,
options={"SKIP_SAVE"},
)
open_viewer: bpy.props.BoolProperty(
open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Open in Viewer",
default=False,
options={"SKIP_SAVE"},
)
sync: bpy.props.BoolProperty(
sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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,
@@ -2322,14 +2322,14 @@ class ActivateDrawingBase(tool.Ifc.Operator):
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
drawing: bpy.props.IntProperty()
should_view_from_camera: bpy.props.BoolProperty(
drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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(
use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Use Quick Preview",
description="Just move the camera to the drawing view, without loading anything else.",
default=False,
@@ -3635,12 +3635,14 @@ class ToggleTargetView(bpy.types.Operator):
bl_label = "Toggle Target View"
bl_options = {"REGISTER", "UNDO"}
target_view: bpy.props.StringProperty()
toggle_all: bpy.props.BoolProperty(
target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
default=False,
options={"SKIP_SAVE"},
)
option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)])
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(ToggleOption)]
)
if TYPE_CHECKING:
target_view: str
+2 -2
View File
@@ -860,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(
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
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(
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
@@ -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() # ty: ignore[missing-argument]
blender_op = bpy.ops.mesh.separate.get_rna_type()
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() # ty: ignore[missing-argument]
blender_op = bpy.ops.object.origin_set.get_rna_type()
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() # ty: ignore[missing-argument]
blender_op = bpy.ops.object.delete.get_rna_type()
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() # ty: ignore[missing-argument]
poll = bpy.ops.object.delete.poll()
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() # ty: ignore[missing-argument]
blender_op = bpy.ops.outliner.delete.get_rna_type()
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes in sync with IFC."
@@ -1060,7 +1060,7 @@ 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() # ty: ignore[missing-argument]
poll = bpy.ops.outliner.delete.poll()
if poll:
return True
cls.poll_message_set("Only available from Outliner.")
@@ -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() # ty: ignore[missing-argument]
poll = bpy.ops.object.duplicate_move.poll()
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() # ty: ignore[missing-argument]
blender_op = bpy.ops.mesh.separate.get_rna_type()
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(): # ty: ignore[missing-argument]
if not bpy.ops.object.join.poll():
cls.poll_message_set("Active object is not EDITable.")
return False
if not context.selected_editable_objects:
@@ -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()
group_type: bpy.props.EnumProperty(
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(tool.Group.GroupType)],
)
option: bpy.props.EnumProperty(
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)],
)
@@ -34,10 +34,8 @@ classes = (
operator.Fetch,
operator.Merge,
operator.ObjectLog,
operator.SelectConflictEntity,
operator.Push,
operator.RefreshGit,
operator.RenameBranch,
operator.SwitchRevision,
operator.InstallGit,
operator.RunGitDiff,
+79 -64
View File
@@ -21,71 +21,65 @@ 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": 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 {},
"repo": cls.repo(),
"remotes": cls.remotes(),
"branch_names": cls.branch_names(),
"remote_names": cls.remote_names(),
"remote_urls": cls.remote_urls(),
"path_ifc": cls.path_ifc(),
"branches_by_hexsha": cls.branches_by_hexsha(),
"tags_by_hexsha": cls.tags_by_hexsha(),
"name_ifc": cls.name_ifc(repo),
"name_ifc": cls.name_ifc(),
"dir_name": cls.dir_name(),
"base_name": cls.base_name(),
"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),
"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(),
"git_exe": cls.git_exe(),
"ifcmerge_exe": cls.ifcmerge_exe(),
}
cls.is_loaded = True
@classmethod
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
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
@classmethod
def tag_names(cls, repo):
if not repo:
return []
return [t.name for t in repo.tags]
def remotes(cls):
if cls.repo():
return cls.repo().remotes
return None
@classmethod
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
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
@classmethod
def path_ifc(cls):
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
return path_ifc
return tool.Ifc.get_path()
return None
@classmethod
@@ -94,8 +88,7 @@ class IfcGitData:
if tool.IfcGitRepo.repo.branches:
return tool.IfcGit.branches_by_hexsha(tool.IfcGitRepo.repo)
except AttributeError:
pass
return {}
return {}
@classmethod
def tags_by_hexsha(cls):
@@ -104,11 +97,12 @@ class IfcGitData:
return {}
@classmethod
def name_ifc(cls, repo):
if bool(tool.Ifc.get()) and repo:
def name_ifc(cls):
if bool(tool.Ifc.get()):
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
return os.path.relpath(path_ifc, repo.working_dir)
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)
return None
@classmethod
@@ -128,28 +122,49 @@ class IfcGitData:
return None
@classmethod
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))
def working_dir(cls):
if cls.repo():
return cls.repo().working_dir
@classmethod
def is_dirty(cls, repo):
if repo and cls.git_exe():
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():
path_ifc = tool.Ifc.get_path()
if os.path.isfile(path_ifc):
return repo.is_dirty(path=path_ifc)
return cls.repo().is_dirty(path=path_ifc)
return False
@classmethod
def current_revision(cls, repo):
def commit(cls):
props = tool.IfcGit.get_ifcgit_props()
if repo and repo.head.is_valid() and len(props.ifcgit_commits) > 0:
return repo.commit()
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()
@classmethod
def git_exe(cls):
+28 -155
View File
@@ -120,11 +120,11 @@ class CommitChanges(bpy.types.Operator):
if props.commit_message == "":
return False
if repo:
if props.new_branch_name in IfcGitData.data["branch_names"]:
if props.new_branch_name in [branch.name for branch in repo.branches]:
cls.poll_message_set("Branch already exists!")
return False
elif not tool.IfcGit.is_valid_ref_format(props.new_branch_name):
if IfcGitData.data["is_detached"]:
if repo.head.is_detached:
cls.poll_message_set("Branch name is invalid or empty!")
return False
elif props.new_branch_name != "":
@@ -134,17 +134,10 @@ class CommitChanges(bpy.types.Operator):
def execute(self, context):
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)
repo = IfcGitData.data["repo"]
core.commit_changes(tool.IfcGit, tool.Ifc, repo)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
IfcGitData.load()
if new_branch_name:
props.display_branch = new_branch_name
return {"FINISHED"}
@@ -164,7 +157,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 IfcGitData.data["tag_names"]
or props.new_tag_name in [tag.name for tag in repo.tags]
):
return False
return True
@@ -172,12 +165,8 @@ class AddTag(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
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)
core.add_tag(tool.IfcGit, repo)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -194,7 +183,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, tool.Ifc)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -202,7 +191,7 @@ class DeleteTag(bpy.types.Operator):
class RefreshGit(bpy.types.Operator):
"""Refresh revision list"""
bl_label = "Refresh"
bl_label = ""
bl_idname = "ifcgit.refresh"
bl_options = {"REGISTER"}
@@ -216,7 +205,8 @@ class RefreshGit(bpy.types.Operator):
def execute(self, context):
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
repo = IfcGitData.data["repo"]
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
tool.IfcGit.decolourise()
return {"FINISHED"}
@@ -225,7 +215,7 @@ class RefreshGit(bpy.types.Operator):
class DisplayRevision(bpy.types.Operator):
"""Colourise objects by selected revision"""
bl_label = "Colourise Revision"
bl_label = ""
bl_idname = "ifcgit.display_revision"
bl_options = {"REGISTER"}
@@ -260,7 +250,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 = "Switch Revision"
bl_label = ""
bl_idname = "ifcgit.switch_revision"
bl_options = {"REGISTER"}
@@ -278,7 +268,7 @@ class SwitchRevision(bpy.types.Operator):
class Merge(bpy.types.Operator):
"""Merges the selected branch into working branch.\nCtrl+click to preview without merging"""
"""Merges the selected branch into working branch"""
bl_label = "Merge this branch"
bl_idname = "ifcgit.merge"
@@ -292,84 +282,15 @@ class Merge(bpy.types.Operator):
return True
return False
def invoke(self, context, event):
if event.ctrl:
core.dry_run_merge(tool.IfcGit, tool.Ifc, self)
refresh()
return {"FINISHED"}
return self.execute(context)
def execute(self, context):
if core.merge_branch(tool.IfcGit, tool.Ifc, self) is not False:
if core.merge_branch(tool.IfcGit, tool.Ifc, self):
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"""
@@ -393,9 +314,9 @@ class Fetch(bpy.types.Operator):
def execute(self, context):
props = tool.IfcGit.get_ifcgit_props()
core.fetch(tool.IfcGit, props.select_remote)
core.refresh_revision_list(tool.IfcGit, tool.Ifc)
refresh()
repo = IfcGitData.data["repo"]
remote = repo.remotes[props.select_remote]
remote.fetch()
return {"FINISHED"}
@@ -415,7 +336,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 IfcGitData.data["remote_names"]
or props.remote_name in [remote.name for remote in repo.remotes]
):
return False
return True
@@ -423,11 +344,8 @@ class AddRemote(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
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)
core.add_remote(tool.IfcGit, repo)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -442,19 +360,8 @@ class DeleteRemote(bpy.types.Operator):
def execute(self, context):
repo = IfcGitData.data["repo"]
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)
core.delete_remote(tool.IfcGit, repo)
core.refresh_revision_list(tool.IfcGit, repo, tool.Ifc)
refresh()
return {"FINISHED"}
@@ -468,8 +375,8 @@ class ObjectLog(bpy.types.Operator):
@classmethod
def poll(cls, context):
if not (obj := context.active_object) or not obj.select_get():
cls.poll_message_set("No selected object")
if not (obj := context.active_object):
cls.poll_message_set("No Active Object")
elif not tool.Blender.get_ifc_definition_id(obj):
cls.poll_message_set("Active Object doesn't have an IFC definition")
else:
@@ -515,7 +422,7 @@ class RunGitDiff(bpy.types.Operator):
)
bl_options = set()
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"})
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
save_to_temp: bool
@@ -538,37 +445,3 @@ 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"}
+19 -13
View File
@@ -17,14 +17,28 @@ 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"
# 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"]]
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"]]
def git_remotes(self: "IfcGitProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
IfcGitData.make_sure_is_loaded()
return [(name, name, name) for name in IfcGitData.data["remote_names"]]
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"]]
def update_revlist(self: "IfcGitProperties", context: bpy.types.Context) -> None:
@@ -76,7 +90,6 @@ 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:
@@ -85,7 +98,6 @@ class IfcGitListItem(PropertyGroup):
author_name: str
author_email: str
message: str
committed_date: int
tags: bpy.types.bpy_prop_collection_idprop[IfcGitTag]
@@ -139,11 +151,6 @@ 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]
@@ -158,4 +165,3 @@ class IfcGitProperties(PropertyGroup):
display_branch: str
select_remote: str
ifcgit_filter: Literal["all", "tagged", "relevant"]
merge_conflicts: str
+26 -62
View File
@@ -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 IfcGitData.data["ifc_is_untracked"]:
if name_ifc in IfcGitData.data["untracked_files"]:
row.operator(
"ifcgit.addfile",
text="Add '" + name_ifc + "' to repository",
@@ -112,13 +112,15 @@ 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="")
row = layout.row()
grouped = layout.row()
column = grouped.column()
row = column.row()
row.prop(props, "display_branch", text="Browse branch")
row.prop(props, "ifcgit_filter", text="Filter revisions")
layout.template_list(
row = column.row()
row.template_list(
"COMMIT_UL_List",
"The_List",
props,
@@ -126,64 +128,20 @@ class IFCGIT_PT_panel(bpy.types.Panel):
props,
"commit_index",
)
row = layout.row(align=True)
column = grouped.column()
row = column.row()
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")
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', '')}")
row = column.row()
row.operator("ifcgit.merge", icon="EXPERIMENTAL", text="")
if not props.ifcgit_commits:
return
@@ -258,7 +216,13 @@ class COMMIT_UL_List(bpy.types.UIList):
):
current_revision = IfcGitData.data["current_revision"]
current_hexsha = current_revision.hexsha if current_revision else None
# 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
lookup = IfcGitData.data["branches_by_hexsha"]
refs = ""
@@ -272,11 +236,11 @@ class COMMIT_UL_List(bpy.types.UIList):
for tag in lookup[item.hexsha]:
refs += "{" + tag.name + "} "
if item.hexsha == current_hexsha:
layout.label(text="[HEAD] " + refs + item.message.split("\n")[0], icon="DECORATE_KEYFRAME")
if commit == current_revision:
layout.label(text="[HEAD] " + refs + commit.message.split("\n")[0], icon="DECORATE_KEYFRAME")
else:
layout.label(text=refs + item.message.split("\n")[0], icon="DECORATE_ANIMATE")
layout.label(text=time.strftime("%c", time.localtime(item.committed_date)))
layout.label(text=refs + commit.message.split("\n")[0], icon="DECORATE_ANIMATE")
layout.label(text=time.strftime("%c", time.localtime(commit.committed_date)))
def draw_filter(self, context, layout):
@@ -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"})
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
@@ -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(
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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()
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
index: int
@@ -452,8 +452,10 @@ 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()
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
)
if TYPE_CHECKING:
index: int
@@ -472,7 +474,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()
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
index: int
+21 -21
View File
@@ -36,9 +36,9 @@ QuickFavoriteValueType = Literal["float_value", "bool_value", "int_value", "stri
class QuickFavoriteEnumItem(PropertyGroup):
name: StringProperty(name="Name", default="")
display_name: StringProperty(name="Display Name", default="")
description: StringProperty(name="Description", default="")
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]
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="")
display_name: StringProperty(name="Display Name", default="")
value_prop: EnumProperty(
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="Value Prop",
items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)),
)
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(
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]
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)
search: StringProperty(
is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration]
search: StringProperty( # pyright: ignore[reportRedeclaration]
name="Search",
default="",
search=get_operator_suggestions,
# Resetting `search_options`, allowing users only to use suggestions.
search_options=set(),
)
properties: CollectionProperty(type=QuickFavoriteProperty)
operator_id: StringProperty(
properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration]
operator_id: StringProperty( # pyright: ignore[reportRedeclaration]
name="Operator ID",
default="",
)
label: StringProperty(
label: StringProperty( # pyright: ignore[reportRedeclaration]
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(
total_storeys: IntProperty( # pyright: ignore[reportRedeclaration]
name="Total Storeys",
description="Number of storeys above object's storey to take into account for resizing",
default=1,
)
override_colour: FloatVectorProperty(
override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration]
name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4
)
quick_favorites: CollectionProperty(type=QuickFavoritesItem)
quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
total_storeys: int
@@ -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,7 +461,6 @@ class PolylineOperator:
self.tool_state.axis_method = None
self.tool_state.plane_method = None
self.tool_state.mode = "Mouse"
tool.Raycast.clear_snap_objs()
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):
@@ -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()
page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
page: int
@@ -271,7 +271,7 @@ 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.EnumProperty(
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
+4 -4
View File
@@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr
class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
is_editing: bpy.props.BoolProperty(
is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Is Editing Paramteric Geometry",
description="Toggle editing parametric geometry.",
default=False,
update=update_is_editing,
)
geometry_source: bpy.props.EnumProperty(
geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Geometry Source",
items=[
("GEONODES", "Geometry Nodes", ""),
("IFCSVERCHOK", "IFC Sverchok", ""),
],
)
geo_nodes: bpy.props.PointerProperty(
geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
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(
sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
name="Sverchok Nodes",
description="Sverchok node tree to use as a source for representation.",
type=bpy.types.NodeTree,
@@ -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_walls_to_underside.__doc__, ui_context
cls.layout, "Extend To Underside", "S_E", bpy.ops.bim.extend_to_underside.__doc__, ui_context
)
if AuthoringData.data["is_flippable_element"]:
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+27 -27
View File
@@ -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()
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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(
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
)
id: bpy.props.IntProperty()
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
ifc_class: bpy.props.EnumProperty(
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
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(
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
)
id: bpy.props.IntProperty()
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
organisation: bpy.props.IntProperty()
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
application_id: int
@@ -86,7 +86,9 @@ 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(items=[(i, i, "") for i in get_args(PresetType)])
preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(PresetType)]
)
if TYPE_CHECKING:
preset: PresetType
@@ -176,9 +178,13 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filter_glob: str
@@ -562,7 +568,7 @@ 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")
query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
query: str
@@ -594,9 +600,11 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
definition: bpy.props.IntProperty()
prop_index: bpy.props.IntProperty()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
assume_unique_by_name: bpy.props.BoolProperty(
name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
definition: int
@@ -951,24 +959,28 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
is_advanced: bpy.props.BoolProperty(
filepath: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Enable Advanced Mode",
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
default=False,
)
use_relative_path: bpy.props.BoolProperty(
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty(
should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty(
import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
name="Import Without IFC Data",
description=(
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
@@ -976,7 +988,9 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
use_detailed_tooltip: bpy.props.BoolProperty(
default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1088,7 +1102,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
else:
return self.finish_loading_project(context)
def finish_loading_project(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
def finish_loading_project(self, context):
try:
filepath = self.get_filepath()
if not self.is_existing_ifc_file():
@@ -1286,7 +1300,7 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
should_select: bool
@@ -1313,7 +1327,7 @@ 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(
query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
@@ -1390,7 +1404,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1414,7 +1428,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1440,9 +1454,9 @@ 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")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1617,7 +1631,7 @@ class ReloadLink(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1633,7 +1647,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1665,8 +1679,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")
mode: bpy.props.EnumProperty(
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Visibility Mode",
items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")),
)
@@ -1807,7 +1821,7 @@ class SelectLinkHandle(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index")
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
link_index: int
@@ -1829,7 +1843,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")
guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
guid: str
@@ -1868,11 +1882,21 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
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)
filter_glob: bpy.props.StringProperty(
default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
json_version: bpy.props.EnumProperty(
items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
) # pyright: ignore[reportRedeclaration]
json_compact: bpy.props.BoolProperty(
name="Export Compact IFCJSON", default=False
) # pyright: ignore[reportRedeclaration]
should_save_as: bpy.props.BoolProperty(
name="Should Save As", default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filter_glob: str
@@ -2029,7 +2053,7 @@ 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()
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
@@ -2419,8 +2443,8 @@ class HideQueriedLinkedElement(bpy.types.Operator):
)
bl_options = {"REGISTER", "UNDO"}
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"})
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"})
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
unhide_all: bool
@@ -2894,8 +2918,12 @@ class IFCFileHandlerOperator(bpy.types.Operator):
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
directory: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
files: bpy.props.CollectionProperty(
type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
directory: str
@@ -2950,7 +2978,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty()
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
measure_type: str
@@ -3049,7 +3077,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty()
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
measure_type: str
@@ -3351,7 +3379,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
filepath: str
+1 -1
View File
@@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup):
),
default=False,
)
should_cache: BoolProperty(
should_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Cache",
description=(
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
@@ -19,7 +19,6 @@
from __future__ import annotations
import os
import shutil
from typing import TYPE_CHECKING
import bpy
@@ -385,18 +384,6 @@ 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"
@@ -240,7 +240,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Property To Selection"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty()
name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
name: str
@@ -280,10 +280,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(
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
index: bpy.props.IntProperty(default=-1)
index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
option: tool.Pset.BulkOperationType
@@ -307,9 +307,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()
index2: bpy.props.IntProperty(default=-1)
option: bpy.props.EnumProperty(
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
@@ -336,7 +336,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(
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
+3 -3
View File
@@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup):
qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"})
# Bulk operations.
psets_to_delete: CollectionProperty(type=DeletePsetEntry)
psets_to_rename: CollectionProperty(type=RenamePropertyEntry)
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry)
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]
if TYPE_CHECKING:
pset_filter: str
@@ -799,7 +799,7 @@ class SelectQueryElements(Operator):
bl_description = "Select elements matching an provided selector query"
bl_options = {"REGISTER", "UNDO"}
query: StringProperty(name="Query")
query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
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(
name: StringProperty( # pyright: ignore[reportRedeclaration]
name="Name",
search=get_name_search_items,
search_options={"SORT"},
)
module: StringProperty()
module: StringProperty() # pyright: ignore[reportRedeclaration]
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(
use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Use Any IfcGroup",
description=(
"By default we're targeting only IfcGroups with SEARCH ObjectType "
@@ -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(
only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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()
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
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(
should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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()
should_unassign_status: bpy.props.BoolProperty(
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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()
work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
work_schedule: int
@@ -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(
editing_type: EnumProperty( # pyright: ignore[reportRedeclaration]
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()
is_visible: BoolProperty(
name: StringProperty() # pyright: ignore[reportRedeclaration]
is_visible: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
)
@@ -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()
container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
container: int
@@ -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()
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
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()
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
boundary_condition: int
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -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()
parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
parent_system_id: int
+3 -3
View File
@@ -26,16 +26,16 @@ from bpy.types import PropertyGroup
class WebProperties(PropertyGroup):
webserver_port: IntProperty(
webserver_port: IntProperty( # pyright: ignore[reportRedeclaration]
name="Webserver Port",
min=0,
max=65535,
)
is_running: BoolProperty(
is_running: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Webserver Running Status",
default=False,
)
is_connected: BoolProperty(
is_connected: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Connection Status",
default=False,
)
+6 -6
View File
@@ -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")
attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration]
"""Full data path to `Attribute`/string property."""
use_relative_path: bpy.props.BoolProperty(
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
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"})
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
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()
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""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()
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
"""Full data path."""
index: bpy.props.IntProperty()
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
data_path: str
+2 -2
View File
@@ -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)
subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration]
# 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()
use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration]
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
update: StringProperty(name="Update", description="Custom update function to be executed")
+1 -1
View File
@@ -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(
should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
name="Always Cache Geometry",
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
)
+17 -95
View File
@@ -56,50 +56,42 @@ 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], commit_message: str, new_branch_name: str = ""
) -> None:
def commit_changes(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], repo: git.Repo) -> None:
"""Commit and create new branches as required"""
path_ifc = ifc.get_path()
if ifcgit.is_head_detached():
ifcgit.git_commit(path_ifc, commit_message)
ifcgit.create_new_branch(new_branch_name)
if repo.head.is_detached:
ifcgit.git_commit(path_ifc)
ifcgit.create_new_branch()
else:
if new_branch_name:
ifcgit.checkout_new_branch(path_ifc, new_branch_name)
ifcgit.git_commit(path_ifc, commit_message)
ifcgit.checkout_new_branch(path_ifc)
ifcgit.git_commit(path_ifc)
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 add_tag(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
ifcgit.add_tag(repo)
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, remote_name: str, remote_url: str) -> None:
ifcgit.add_remote(repo, remote_name, remote_url)
def add_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
ifcgit.add_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 delete_remote(ifcgit: type[tool.IfcGit], repo: git.Repo) -> None:
ifcgit.delete_remote(repo)
def push(ifcgit: type[tool.IfcGit], repo: git.Repo, remote_name: str, operator: bpy.types.Operator) -> None:
error_message = ifcgit.push(repo, remote_name, ifcgit.get_active_branch_name())
error_message = ifcgit.push(repo, remote_name, repo.active_branch.name)
if error_message:
operator.report({"ERROR"}, error_message)
def refresh_revision_list(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc]) -> None:
ifcgit.clear_merge_conflicts()
if ifcgit.repo_has_commits():
def refresh_revision_list(ifcgit: type[tool.IfcGit], repo: git.Repo, ifc: type[tool.Ifc]) -> None:
if repo.heads:
ifcgit.refresh_revision_list(ifc.get_path())
@@ -133,76 +125,10 @@ 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) -> bool | None:
def merge_branch(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(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")
ifcgit.execute_merge(path_ifc, operator)
def entity_log(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], step_id: int, operator: bpy.types.Operator) -> None:
@@ -219,9 +145,5 @@ 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)
-54
View File
@@ -535,60 +535,6 @@ 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
+1 -2
View File
@@ -987,8 +987,7 @@ 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
if props.change_cost_item_parent == True:
props.change_cost_item_parent = False
props.change_cost_item_parent = False
@classmethod
def load_cost_item_quantities(cls, cost_item: Optional[ifcopenshell.entity_instance] = None) -> None:
+97 -229
View File
@@ -18,14 +18,13 @@
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, Union
from typing import TYPE_CHECKING, Any, Literal, Union
import bpy
@@ -129,27 +128,39 @@ 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, branch_name: str) -> None:
def checkout_new_branch(cls, path_file: str) -> None:
"""Create a branch and move uncommitted changes to this branch"""
IfcGitRepo.repo.git.checkout(b=branch_name)
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()
@classmethod
def git_commit(cls, path_file: str, commit_message: str) -> None:
def git_commit(cls, path_file: str) -> None:
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo
if os.name == "nt":
cls.dos2unix(path_file)
repo.index.add(os.path.normpath(path_file))
repo.index.commit(message=commit_message)
repo.index.commit(message=props.commit_message)
props.commit_message = ""
@classmethod
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)
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 = ""
@classmethod
def delete_tag(cls, repo: git.Repo, tag_name: git.TagReference) -> None:
@@ -157,17 +168,20 @@ class IfcGit:
repo.delete_tag(tag_name)
@classmethod
def rename_branch(cls, repo: git.Repo, new_name: str) -> None:
repo.active_branch.rename(new_name)
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 = ""
@classmethod
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:
def delete_remote(cls, repo: git.Repo) -> None:
props = cls.get_ifcgit_props()
remote_name = props.select_remote
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]:
@@ -179,25 +193,16 @@ class IfcGit:
return exc.stderr
@classmethod
def is_head_detached(cls) -> bool:
return bool(IfcGitRepo.repo.head.is_detached)
@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:
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(branch_name)
new_branch = repo.create_head(props.new_branch_name)
new_branch.checkout()
props.display_branch = props.new_branch_name
props.new_branch_name = ""
bpy.ops.ifcgit.refresh()
@classmethod
def clear_commits_list(cls) -> None:
@@ -217,7 +222,7 @@ class IfcGit:
rev=[props.display_branch],
)
)
commits_relevant = set(
commits_relevant = list(
git.objects.commit.Commit.iter_items(
repo=repo,
rev=[props.display_branch],
@@ -225,17 +230,11 @@ 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 not is_relevant(commit):
elif props.ifcgit_filter == "relevant" and commit not in commits_relevant:
continue
props.ifcgit_commits.add()
@@ -244,8 +243,7 @@ class IfcGit:
list_item.message = commit.message
list_item.author_name = commit.author.name
list_item.author_email = commit.author.email
list_item.committed_date = int(commit.committed_date)
if is_relevant(commit):
if commit in commits_relevant:
list_item.relevant = True
if commit.hexsha in lookup:
for tag in lookup[commit.hexsha]:
@@ -286,25 +284,16 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
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
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
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
@@ -404,43 +393,20 @@ class IfcGit:
model = tool.Ifc.get()
modified_step_ids = {"modified": set()}
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"):
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)
if 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())
return modified_step_ids
@@ -492,56 +458,38 @@ 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", 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, "cmd", "ifcmerge $BASE $LOCAL $REMOTE $MERGED")
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", 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, "cmd", "ifcmerge $BASE $REMOTE $LOCAL $MERGED")
config_writer.set_value(section, "trustExitCode", True)
@classmethod
@@ -571,117 +519,49 @@ class IfcGit:
output.write(line + b"\n")
@classmethod
def get_selected_branch(cls) -> Union[str, None]:
"""Return the name of the branch at the selected commit matching display_branch, or None."""
def execute_merge(cls, path_ifc: str, operator: bpy.types.Operator) -> Union[None, Literal[False]]:
props = cls.get_ifcgit_props()
repo = IfcGitRepo.repo
item = props.ifcgit_commits[props.commit_index]
lookup = cls.branches_by_hexsha(repo)
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
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)
@classmethod
def get_merge_tool(cls, branch_name: str) -> str:
if re.match("^(origin/)?(HEAD|main|master)$", branch_name):
return "ifcmerge"
return "ifcmerge-forward"
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 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"
props.display_branch = repo.active_branch.name
@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
cls.load_project(path_ifc)
cls.refresh_revision_list(path_ifc)
cls.decolourise()
@classmethod
def entity_log(cls, path_ifc: str, step_id: int) -> str:
@@ -709,18 +589,6 @@ 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()
+13 -619
View File
@@ -16,16 +16,12 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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
@@ -45,7 +41,6 @@ class Raycast(bonsai.core.tool.Raycast):
(0, -offset),
(offset, -offset),
)
snap_objs = []
@classmethod
def get_visible_objects(cls, context: bpy.types.Context):
@@ -74,10 +69,8 @@ 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] = []
@@ -101,25 +94,8 @@ 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)
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
if coord_2d is not None:
transposed_bbox.append(coord_2d)
region = context.region
borders = (0, region.width, 0, region.height)
@@ -141,96 +117,6 @@ 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
@@ -346,158 +232,6 @@ 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,
@@ -723,9 +457,7 @@ 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):
snap_obj = cls.create_snap_obj(obj)
if snap_obj is not None:
objs_to_raycast.append(snap_obj)
objs_to_raycast.append(obj)
return objs_to_raycast
@@ -742,6 +474,12 @@ 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:
@@ -776,20 +514,19 @@ class Raycast(bonsai.core.tool.Raycast):
ray_origin, ray_target, ray_direction = cls.get_viewport_ray_data(context, event)
for snap_obj in objs_to_raycast:
for obj in objs_to_raycast:
if not include_wireframes and (
snap_obj.obj.type in {"EMPTY", "CURVE"}
or (hasattr(snap_obj.obj.data, "polygons") and len(snap_obj.obj.data.polygons) == 0)
obj.type in {"EMPTY", "CURVE"} or (hasattr(obj.data, "polygons") and len(obj.data.polygons) == 0)
):
continue
hit_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, snap_obj.obj)
snap_obj, hit, face_index = cls.cast_rays_to_single_object(context, event, 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 = hit_obj
best_obj = snap_obj
best_hit = hit
best_face_index = face_index
@@ -799,79 +536,6 @@ 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
@@ -883,273 +547,3 @@ 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:
# Handle objects modified while a modal operator is active.
# Example: adding a door or window alters the wall geometry.
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)
for v1, v2 in zip(obj.data.vertices, snap_obj.verts_3d):
if (obj.matrix_world @ v1.co) != v2:
cls.snap_objs.pop(i)
snap_obj = SnapObj(obj)
cls.snap_objs.append(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.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
+50 -29
View File
@@ -388,36 +388,57 @@ class Snap(bonsai.core.tool.Snap):
# Objects
objs_to_raycast = tool.Raycast.filter_objects_to_raycast(context, event, objs_2d_bbox)
closest_snaps = tool.Raycast.ray_cast_and_get_closest_to_camera_snaps(context, event, objs_to_raycast)
detected_snaps.extend(closest_snaps)
xray_mode = (space.shading.type == "SOLID" and space.shading.show_xray) or (
space.shading.type == "WIREFRAME" and space.shading.show_xray_wireframe
)
# Wireframes
# For wireframe we have to get all the objects so we can further calculate edge intersection
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
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)
if (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)
# snap to cut geometry (e.g. in plan view)
if CutDecorator.installed:
+5 -1
View File
@@ -997,7 +997,9 @@ class Spatial(bonsai.core.tool.Spatial):
return obj
@classmethod
def set_obj_origin_to_polygon_center(cls, obj: bpy.types.Object, poly: Polygon, polygon_is_si: bool = True) -> None:
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:
@@ -1005,6 +1007,7 @@ class Spatial(bonsai.core.tool.Spatial):
else:
obj.location = Vector((centroid.x * unit_scale, centroid.y * unit_scale, 0))
@classmethod
def get_2d_vertices_from_polygon(
cls,
@@ -1192,6 +1195,7 @@ class Spatial(bonsai.core.tool.Spatial):
) -> None:
bonsai.core.type.assign_type(ifc, tool.Model, type, element=element, type=relating_type)
@classmethod
def set_space_visibility(cls, is_visible: bool) -> None:
if tool.Ifc.get().schema == "IFC2X3":
@@ -7,7 +7,7 @@ Python code formatters
For Python code formatting, we use `Black code formatter <https://pypi.org/project/black/>`__,
black settings are stored in the repository's pyproject.toml.
We have GitHub workflow `ci-lint` to maintain black formatting across the repository.
We have GitHub workflow `ci-black-formatting` 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:
@@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following:
* - File
- What to update
* - ``.github/workflows/ci-lint.yaml``
* - ``.github/workflows/ci-black-formatting.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
@@ -44,8 +44,6 @@ When a new Blender version is released and supported:
* - File
- What to update
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
@@ -59,64 +57,9 @@ When Blender ships with a new Python version:
* - File
- What to update
* - ``.github/workflows/ci-lint.yaml``
* - ``.github/workflows/ci-black-formatting.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 <https://pypi.org/project/bcf-client/>`_ to PyPI
- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases
- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd <https://pypi.org/project/bsdd/>`_ to PyPI
- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d <https://pypi.org/project/ifc4d/>`_ to PyPI
- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d <https://pypi.org/project/ifc5d/>`_ to PyPI
- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash <https://pypi.org/project/ifcclash/>`_ to PyPI
- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases
- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv <https://pypi.org/project/ifccsv/>`_ to PyPI
- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff <https://pypi.org/project/ifcdiff/>`_ to PyPI
- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit <https://pypi.org/project/ifcedit/>`_ to PyPI
- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm <https://pypi.org/project/ifcfm/>`_ to PyPI
- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson <https://pypi.org/project/ifccityjson/>`_ to PyPI
- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp <https://pypi.org/project/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 <https://pypi.org/project/ifcopenshell/>`_ wheels to PyPI
- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch <https://pypi.org/project/ifcpatch/>`_ to PyPI
- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery <https://pypi.org/project/ifcquery/>`_ to PyPI
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
- ``.github/workflows/publish-bonsai-releases.yml`` - publish Bonsai Blender extension to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
- ❗ Requires ``BLENDER_EXTENSIONS_TOKEN`` secret to be set - ❗ not yet configured
- Publishing documentation and websites (see `website <https://github.com/IfcOpenShell/website>`_ repository):
- `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org <https://docs.ifcopenshell.org>`_ (`ifcopenshell_org_docs <https://github.com/IfcOpenShell/ifcopenshell_org_docs>`_ repo)
- `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org <https://docs.bonsaibim.org>`_ (`bonsaibim_org_docs <https://github.com/IfcOpenShell/bonsaibim_org_docs>`_ repo)
- `publish-websites.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/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
-38
View File
@@ -396,44 +396,6 @@ 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
+1 -31
View File
@@ -133,11 +133,7 @@ class PanelSpy:
self.spied_labels.append(kwargs["text"])
return self
elif self.spied_attr == "prop":
if args:
props, name = args
else:
props = kwargs.get("data")
name = kwargs.get("property")
props, name = args
props: bpy.types.bpy_struct
text = kwargs.get("text", props.bl_rna.properties[name].name)
icon = kwargs.get("icon", None)
@@ -394,32 +390,6 @@ 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'))
-7
View File
@@ -102,13 +102,6 @@ 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)
-329
View File
@@ -1,329 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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)
-49
View File
@@ -1,49 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
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
-581
View File
@@ -1,581 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2025 Dion Moult <dion@thinkmoult.com>
#
# 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 <http://www.gnu.org/licenses/>.
# 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
+2 -2
View File
@@ -1,7 +1,7 @@
SHELL := sh
IS_STABLE:=FALSE
PYTHON:=python3
PIP:=pip3
PYTHON:=python3.11
PIP:=pip3.11
VERSION:=$(shell cat ../../VERSION)
VERSION_DATE:=$(shell date '+%y%m%d')
SED:=sed -i
@@ -237,7 +237,7 @@
"Area": "get_net_side_area",
"Height": "get_height",
"Perimeter": "get_rectangular_perimeter",
"Width": "get_x"
"Width": "get_length"
}
},
"IfcDuctFitting + IfcDuctFittingType": {
-1
View File
@@ -1 +0,0 @@
ai-chat.ifcopenshell.org
-10
View File
@@ -1,10 +0,0 @@
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
```
-184
View File
@@ -1,184 +0,0 @@
// 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());
}
-20
View File
@@ -1,20 +0,0 @@
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();
}

Some files were not shown because too many files have changed in this diff Show More