Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Schultz 7c738c15d9 Fix slab layer geometry for rotated and angled slabs
Three bugs fixed:

1. EditAssignedMaterial was sweeping all slabs that share a layer set
   when changing material properties, instead of updating only the
   selected element's assigned material.

2. slice_layerset_mesh (loader.py): layer bisect positions were scaled
   incorrectly due to a stale unit_scale and a reversed/missing
   DirectionSense guard. Now always recalculates unit_scale fresh and
   correctly applies sense_factor.

3. change_thickness (slab.py): extrusion depth was computed as
   `thickness / cos(obj.rotation_euler.x)`, which incorrectly scaled
   ObjectPlacement-rotated slabs (where the extrusion direction is
   local Z and no scaling is needed). The correct formula is
   `thickness / extrusion_vec.z`, which handles both ObjectPlacement
   rotation (extrusion_vec.z ≈ 1.0 → no scale) and ExtrudedDirection
   tilts (extrusion_vec.z < 1.0 → scale up) uniformly. The resulting
   slab was 1.414× too thick for 45°-rotated slabs, making both
   material layers appear fatter than specified.

   slice_layerset_mesh retains a depth_scale safety factor
   (extrusion_vec.z × ifc_depth / total_layer_thickness) as a
   robustness guard for IFC files from other authoring tools where
   extrusion depth may not match the sum of LayerThicknesses.
2026-03-14 12:21:54 -05:00
Ryan Schultz 036ee098a6 Fix slab layer geometry: custom offset isolation, unit scale, and layer ordering
Three related bugs fixed in the slab material layer set workflow:

1. EditAssignedMaterial (operator.py): Applying a custom offset to one slab
   instance incorrectly regenerated geometry for ALL slabs sharing the same
   IfcMaterialLayerSet. Replaced regenerate_from_layer_set (sweeps all users)
   with per-element regenerate_from_occurence for AXIS3 slabs and targeted
   recalculate_walls for AXIS2 walls. Each element's IfcMaterialLayerSetUsage
   attributes are now updated individually before regeneration.

2. slice_layerset_mesh (loader.py): Loader.unit_scale is a class variable only
   set during full file import, so it was stale (= 1) during live geometry
   updates on foot-based IFC files. Layer bisect planes were being computed in
   IFC feet while the mesh was in Blender metres, placing all cuts completely
   outside the mesh. Fixed by computing unit_scale fresh from the IFC file on
   each call via ifcopenshell.util.unit.calculate_unit_scale.

3. OffsetFromReferenceLine stale / layer order reversed (slab.py, loader.py):
   A guard (and custom_offset is None) in change_thickness prevented writing
   the correct OffsetFromReferenceLine (position.z) to the usage when a custom
   offset was active. This left the value at 0.0 instead of the actual slab
   bottom (e.g. -1.0 IFC units for a TOP-reference slab), so the bisect
   starting point co was at the reference plane rather than the slab bottom,
   reversing layer assignments or missing layers entirely. Removed the guard —
   safe because each element has its own IfcMaterialLayerSetUsage instance.
   Reverted the AXIS3 bisect normal back to (0,0,1) (upward from co at slab
   bottom) which is correct once OffsetFromReferenceLine is properly set.
2026-03-14 12:21:35 -05:00
438 changed files with 3773 additions and 24246 deletions
+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.20
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.20
with:
key: ubuntu-22.04-${{ runner.arch }}
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
python3 ../nix/cache_dependencies.py unpack
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
}
- name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
with:
key: win-${{ matrix.arch }}
# Windows ccache needs ~1GB
@@ -1,4 +1,4 @@
name: ci-lint
name: ci-black-formatting
on:
push:
@@ -7,9 +7,6 @@ on:
jobs:
lint-formatting:
runs-on: ubuntu-latest
env:
MIN_IOS_PY_VERSION: "3.10"
MIN_BLENDER_PY_VERSION: "3.11"
steps:
- name: Action - checkout repository
uses: actions/checkout@v6
@@ -17,12 +14,12 @@ jobs:
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_IOS_PY_VERSION }}
python-version: "3.10"
- name: Action - install python
uses: actions/setup-python@v6
with:
python-version: ${{ env.MIN_BLENDER_PY_VERSION }}
python-version: "3.11"
- name: Install dependencies
run: |
@@ -30,7 +27,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
@@ -39,8 +35,8 @@ jobs:
ERROR=0
# Using 2 Python versions - one minimum required for IfcOpenShell
# and other that's used by Blender currently.
python${{ env.MIN_IOS_PY_VERSION }} -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python${{ env.MIN_BLENDER_PY_VERSION }} -W error -m compileall -q src/bonsai || ERROR=1
python3.10 -W error -m compileall -q src/ifcopenshell-python || ERROR=1
python3.11 -W error -m compileall -q src/bonsai || ERROR=1
exit $ERROR
continue-on-error: true
@@ -58,13 +54,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: |
@@ -113,7 +102,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: >-
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
-
name: ccache
uses: hendrikmuhs/ccache-action@v1.2.22
uses: hendrikmuhs/ccache-action@v1.2.20
-
name: Build ifcopenshell
@@ -91,7 +91,7 @@ jobs:
lfs: true
- name: Download
uses: actions/download-artifact@v8.0.1
uses: actions/download-artifact@v8.0.0
with:
# Artifact name
name: ifcos-artifacts
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py310, py311, py312, py313, py314]
pyver: [py39, py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py310, py311, py312, py313, py314]
pyver: [py39, py310, py311, py312, py313, py314]
config:
- {
name: "Windows 64bit",
-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 -2
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.20
with:
key: ubuntu-22.04-${{ runner.arch }}
@@ -254,7 +254,6 @@ jobs:
cd ../ifcpatch && make test || ERROR=1
pip install -e ../ifctester --no-deps
cd ../ifctester && make test || ERROR=1
make build-ids-docs || ERROR=1
# Run mathutils related tests at the end to ensure no other code is relying on mathutils.
cd ../ifcopenshell-python
pip install mathutils
+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
+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
+3 -16
View File
@@ -5,8 +5,6 @@
/_installed-vs*-x*/
/build/
/src/examples/build/
# ifctester docs output
/src/ifctester/test/build/
# output directories
/cmake/out/
@@ -14,7 +12,6 @@
/src/ifcmax/out/
/src/ifcwrap/out/
/src/qtviewer/out/
/src/ifctester/webapp/public/pyodide/
/win/BuildDepsCache*.txt
@@ -83,14 +80,10 @@ src/ifcopenshell-python/test/build
# bonsai i18n
src/bonsai/bonsai/translations.py
# bonsai external dependencies (cloned for just ty checks)
src/bonsai/external_dependencies/
# bonsai test temp/cache files
# bonsai test temp files
src/bonsai/test/files/temp
src/bonsai/test/files/*.cache.blend
src/bonsai/test/files/*.cache.json
src/bonsai/test/files/*.cache.sqlite
src/bonsai/test/files/basic.ifc.cache.blend
src/bonsai/test/files/basic.ifc.cache.sqlite
# bonsai data
src/bonsai/bonsai/bim/data/build/
@@ -122,9 +115,3 @@ dev_environment.bat
src/ifcopenshell-python/ifcopenshell/express/*.exp
src/ifcopenshell-python/ifcopenshell/express/*.exp.cache.dat
# temp files from AI coding tools
*.claude
*.py.tmp*
*.json.tmp*
+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 -19
View File
@@ -13,7 +13,6 @@ import hashlib
import os
import pathlib
import re
import subprocess
from typing import NoReturn
from urllib import request
@@ -21,7 +20,7 @@ from github import Github
def get_repo_tag_names() -> list[str]:
git_return = subprocess.check_output("git tag -l", text=True)
git_return = os.popen("git tag -l").read()
tag_names = [tag_name for tag_name in git_return.split("\n") if tag_name]
print(f"{len(tag_names)} tag_names found in repo")
return tag_names
@@ -79,10 +78,6 @@ def get_release_zip(tag: str) -> tuple[str, str]:
raise Exception(f"Couldn't find the release matching '{python_version}' and '{TARGET_OS}' in tag '{tag}'.")
def run(command: str) -> None:
subprocess.check_output(command)
start = datetime.datetime.now()
URL_CHOCO_PACKAGE = "https://community.chocolatey.org/packages/blender"
@@ -102,7 +97,7 @@ should_release = False
target_release_tag = ""
TARGET_OS = "windows-x64"
git_status = subprocess.check_output("git status", text=True)
git_status = os.popen("git status").read()
print(git_status)
for tag_name in get_repo_tag_names():
@@ -152,7 +147,7 @@ blenderbim_build_version = target_release_tag.replace("blenderbim-", "")
# url_blenderbim_py3x_win_zip
release_zip_file_name, url_blenderbim_py3x_win_zip = get_release_zip(target_release_tag)
subprocess.check_call(f"wget {url_blenderbim_py3x_win_zip} --no-verbose")
os.popen(f"wget {url_blenderbim_py3x_win_zip} --no-verbose").read()
# sha256sum_blenderbim_py310_win_zip
sha256sum_blenderbim_py3x_win_zip = get_file_sha256_hash(release_zip_file_name)
@@ -206,13 +201,13 @@ print("[INFO] inserting dynamic chocolatey package parameters successful")
print("\n_____ build choco.exe with mono")
choco_version = "1.1.0"
run(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet")
run(f"tar -xzf {choco_version}.tar.gz")
os.popen(f"wget https://github.com/chocolatey/choco/archive/refs/tags/{choco_version}.tar.gz --quiet").read()
os.popen(f"tar -xzf {choco_version}.tar.gz").read()
print("choco tar unpack successful")
os.chdir("choco-1.1.0")
run("./build.sh")
os.popen("./build.sh").read()
run("cp -r build_output/chocolatey /opt/chocolatey")
os.popen("cp -r build_output/chocolatey /opt/chocolatey").read()
os.chdir(BLENDERBIM_DIR)
if pathlib.Path("/opt/chocolatey/choco.exe").exists():
@@ -220,15 +215,11 @@ if pathlib.Path("/opt/chocolatey/choco.exe").exists():
print("\n_____ build choco pack")
run("mono /opt/chocolatey/choco.exe pack --allow-unofficial")
run(
'mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial'
)
os.popen("mono /opt/chocolatey/choco.exe pack --allow-unofficial").read()
os.popen('mono /opt/chocolatey/choco.exe setapikey --key="{choco_token}" --source="https://push.chocolatey.org/" --allow-unofficial').read()
print("\n_____ build choco push")
run(
'mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose'
)
os.popen('mono /opt/chocolatey/choco.exe push --source="https://push.chocolatey.org/" --key="$CHOCO_TOKEN" --allow-unofficial --verbose').read()
print(f"choco push of version: {target_release_tag} successful!")
print(f"it took: {datetime.datetime.now() - start}")
+3 -12
View File
@@ -1094,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
@@ -1524,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("")
-3
View File
@@ -41,9 +41,6 @@ def pack_dependencies(install_dir: Path) -> None:
if not dependency_path.is_dir():
continue
dependency_name = dependency_path.name
# Skip ifcopenshell - it's a build output, not a dependency to reuse across builds.
if dependency_name == "ifcopenshell":
continue
tar_path = install_dir / f"{CACHE_PREFIX}{dependency_name}.tar.gz"
if tar_path.exists():
print(f"Skipping existing cache: '{tar_path}'")
+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},
)
+2 -178
View File
@@ -3,10 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.9",
"ruff==0.15.5",
"poethepoet",
"ty==0.0.29",
"gersemi==0.26.1",
"gersemi==0.26.0",
]
[tool.black]
@@ -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
@@ -82,137 +78,6 @@ ignore = [
"UP032", # Replace .format with f-string
]
[tool.ty.rules]
all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
cyclic-type-alias-definition = "error"
dataclass-field-order = "error"
duplicate-base = "error"
duplicate-kw-only = "error"
empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
ineffective-final = "error"
instance-layout-conflict = "error"
invalid-dataclass = "error"
invalid-dataclass-override = "error"
invalid-enum-member-annotation = "error"
invalid-explicit-override = "error"
invalid-frozen-dataclass-subclass = "error"
invalid-generic-class = "error"
invalid-generic-enum = "error"
invalid-ignore-comment = "error"
invalid-legacy-positional-parameter = "error"
invalid-legacy-type-variable = "error"
invalid-named-tuple = "error"
invalid-newtype = "error"
invalid-overload = "error"
invalid-paramspec = "error"
invalid-protocol = "error"
invalid-syntax-in-forward-annotation = "error"
invalid-total-ordering = "error"
invalid-type-alias-type = "error"
invalid-type-checking-constant = "error"
invalid-type-guard-definition = "error"
invalid-type-variable-bound = "error"
invalid-type-variable-constraints = "error"
invalid-typed-dict-header = "error"
invalid-typed-dict-statement = "error"
override-of-final-method = "error"
override-of-final-variable = "error"
possibly-missing-import = "error"
possibly-missing-submodule = "error"
# Has false positives due to ty walrus operator bug.
# possibly-unresolved-reference = "error"
raw-string-type-annotation = "error"
redundant-final-classvar = "error"
shadowed-type-variable = "error"
subclass-of-final-class = "error"
super-call-in-named-tuple-method = "error"
unavailable-implicit-super-arguments = "error"
unbound-type-variable = "error"
undefined-reveal = "error"
unresolved-global = "error"
unresolved-import = "error"
unresolved-reference = "error"
unused-ignore-comment = "error"
unused-type-ignore-comment = "error"
useless-overload-body = "error"
# Non-structural rules:
deprecated = "error"
zero-stepsize-in-slice = "error"
possibly-missing-implicit-call = "error"
unused-awaitable = "error"
# Function argument rules:
# Conflicts with `ifcopenshell.api.geometry.add_representation` type of callables we have, confusing them with a module.
# call-non-callable = "error"
conflicting-argument-forms = "error"
# Too many false positives.
# invalid-argument-type = "error"
missing-argument = "error"
parameter-already-assigned = "error"
positional-only-parameter-as-kwarg = "error"
too-many-positional-arguments = "error"
unknown-argument = "error"
# Has a lot of warnings due to current ty walrus operator issues.
# index-out-of-bounds = "error"
# unresolved-attribute = "error"
[tool.ty.environment]
extra-paths = [
"src/bonsai/external_dependencies",
"src/bcf",
"src/bsdd",
"src/bonsai",
"src/ifc4d",
"src/ifc5d",
"src/ifccityjson",
"src/ifcclash",
"src/ifccsv",
"src/ifcdiff",
"src/ifcfm",
"src/ifcopenshell-python",
"src/ifcpatch",
"src/ifctester",
]
[tool.ty.src]
exclude = [
# External dependencies cloned for type checking only.
"src/bonsai/external_dependencies",
# Submodules.
"src/ifcopenshell-python/ifcopenshell/express",
"src/ifcopenshell-python/ifcopenshell/mvd",
"src/ifcopenshell-python/ifcopenshell/simple_spf",
"src/svgfill/3rdparty",
# Has special dependencies.
"src/ifcopenshell-python/ifcopenshell/geom/app.py",
"src/ifcopenshell-python/ifcopenshell/geom/code_editor_pane.py",
"src/ifcopenshell-python/ifcopenshell/util/doc.py",
"src/ifcopenshell-python/ifcopenshell/util/generate_pset_templates.py",
"src/ifcopenshell-python/ifcopenshell/util/ifc4x3dev_scrape_data_for_docs.py",
# Too esoteric.
"src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py",
"src/ifc2ca/templates",
# Too dev.
"src/bcf/setup.py",
"src/bsdd/yml_to_classes.py",
# Deprecated.
"src/ifc2ca/_deprecated",
]
[tool.poe.tasks]
ruff-main = "ruff check --extend-exclude nix/build-all.py"
@@ -222,47 +87,6 @@ ruff.sequence = ["ruff-main", "ruff-old"]
black = "black ."
ty.sequence = ["ty-bonsai", "ty-ios"]
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
ty-venv.sequence = ["bonsai-deps", "ty-venv-bonsai", "ty-venv-ios"]
ty-venv-bonsai.sequence = [
{cmd = "uv venv src/bonsai/.venv --python=3.11 --allow-existing"},
{cmd = "uv pip install -r src/bonsai/type-check-requirements.txt --python=src/bonsai/.venv"},
]
ty-venv-ios.sequence = [
{cmd = "uv venv src/ifcopenshell-python/.venv --python=3.10 --allow-existing"},
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
]
format.sequence = ["black", "ruff-main", "ruff-old"]
cmake-format = "gersemi . --in-place"
[tool.poe.tasks.ty-ios]
# --ignore unresolved-reference: walrus operator false positives in ty.
cmd = """
ty check
src/bcf
src/bsdd
src/ifc2ca
src/ifc4d
src/ifc5d
src/ifccityjson
src/ifcclash
src/ifccsv
src/ifcdiff
src/ifcfm
src/ifcopenshell-python
src/ifcpatch
src/ifctester
--python=src/ifcopenshell-python/.venv
--ignore unresolved-reference
"""
[tool.poe.tasks.bonsai-deps]
help = "Clone or update Bonsai external dependencies."
cmd = "python src/bonsai/scripts/bonsai_deps.py"
+3 -3
View File
@@ -34,8 +34,8 @@ client_id, client_secret = "", ""
class OAuthReceiver(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
self.server.auth_code = query.get("code", [""])[0]
self.server.auth_state = query.get("state", [""])[0]
self.server.auth_code = query.get("code", [""])[0] # type: ignore
self.server.auth_state = query.get("state", [""])[0] # type: ignore
self.send_response(200)
self.send_header("Content-type", "text/plain")
self.end_headers()
@@ -255,7 +255,7 @@ class BcfClient:
project_id: str = "",
topics: str = "",
query_string: Optional[str] = None,
) -> None:
) -> list[Any]:
# return self.get(
# f"/projects/{project_id}/topics",
# {
+10 -14
View File
@@ -173,17 +173,16 @@ def assert_viewpoints(viewpoints):
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=False,
),
exceptions=expected_exception,
default_visibility=False,
),
@@ -194,7 +193,6 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
camera_direction=mdl.Direction(x=0.6745243072509766, y=-0.6599355936050415, z=-0.33091068267822266),
camera_up_vector=mdl.Direction(x=0.2271970510482788, y=-0.24091780185699463, z=0.9435783624649048),
field_of_view=60,
aspect_ratio=1.0,
),
guid="21dd4807-e9af-439e-a980-04d913a6b1ce",
)
@@ -202,17 +200,16 @@ def assert_second_viewpoint(viewpoint, expected_selection, expected_exception, e
assert viewpoint.snapshot is not None
# TODO: dead code - ported from v2 but buildingSMART/BCF-XML has no v3 MaximumInformation.bcf equivalent
def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, expected_coloring) -> None:
expected_vp = mdl.VisualizationInfo(
components=mdl.Components(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
selection=expected_selection,
visibility=mdl.ComponentVisibility(
view_setup_hints=mdl.ViewSetupHints(
spaces_visible=False,
space_boundaries_visible=False,
openings_visible=True,
),
exceptions=expected_exception,
default_visibility=True,
),
@@ -223,7 +220,6 @@ def assert_third_viewpoint(viewpoint, expected_selection, expected_exception, ex
camera_direction=mdl.Direction(x=0.7232745289802551, y=0.5967116951942444, z=-0.3475759029388428),
camera_up_vector=mdl.Direction(x=0.27662187814712524, y=0.21082592010498047, z=0.937567412853241),
field_of_view=60,
aspect_ratio=1.0,
),
guid="81daa431-bf01-4a49-80a2-1ab07c177717",
)
+5 -6
View File
@@ -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
@@ -234,16 +232,18 @@ endif
cd build/bonsai/bim/data/brick/ && wget https://github.com/BrickSchema/Brick/releases/download/nightly/Brick.ttl
# Required for hipped roof generation
cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/prochitecture/bpypolyskel" --no-deps -w wheels/
# TODO: Use official repo once https://github.com/prochitecture/bpypolyskel/pull/22 is merged.
cd build && . env/$(VENV_ACTIVATE) && $(PYTHON) -m pip wheel "git+https://github.com/Andrej730/bpypolyskel.git@pyproject_toml" --no-deps -w wheels/
# folder for executable files
mkdir -p build/bonsai/libs/bin
# required for three-way git merging
ifeq ($(PLATFORM), win)
cd build/bonsai/libs/bin && wget https://github.com/brunopostle/ifcmerge/releases/download/$(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 +262,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
+3 -1
View File
@@ -72,7 +72,9 @@ class IfcExporter:
def set_header(self):
self.file.header.file_name.name = os.path.basename(self.ifc_export_settings.output_file)
self.file.header.file_name.time_stamp = datetime.datetime.now().astimezone().replace(microsecond=0).isoformat()
self.file.header.file_name.time_stamp = (
datetime.datetime.utcnow().replace(tzinfo=datetime.UTC).astimezone().replace(microsecond=0).isoformat()
)
self.file.header.file_name.preprocessor_version = "IfcOpenShell {}".format(ifcopenshell.version)
self.file.header.file_name.originating_system = "{} {}".format(
self.get_application_name(), tool.Blender.get_bonsai_version()
+5 -8
View File
@@ -45,19 +45,16 @@ from bonsai.bim.module.nest.decorator import NestDecorator
cwd = os.path.dirname(os.path.realpath(__file__))
global_subscription_owner = object()
# Separate owner for per-object msgbus subscriptions (name, active_material_index).
# Using a dedicated owner allows clearing all per-object subscriptions at once
# during undo/redo without affecting other global subscriptions.
object_subscription_owner = object()
def name_callback(obj: Union[bpy.types.Object, bpy.types.Material], data: str) -> None:
try:
obj.name
except:
# The object is invalid but somehow still has a callback.
# This can occur during undo/redo when the Python wrapper is stale.
return
# The object is invalid but somehow still has a callback. Clear all
# msgbus subscriptions to prevent useless further triggers.
bpy.msgbus.clear_by_owner(obj)
return # In case the object RNA is gone during an undo / redo operation
# Blender names are up to 63 UTF-8 bytes
if len(bytes(obj.name, "utf-8")) >= 63:
return
@@ -192,7 +189,7 @@ def subscribe_to(obj: bpy.types.ID, data_path: str, callback: Callable[[bpy.type
return
bpy.msgbus.subscribe_rna(
key=subscribe_to,
owner=object_subscription_owner,
owner=obj,
args=(
obj,
data_path,
+9 -4
View File
@@ -316,8 +316,11 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
obj = IfcStore.get_object_by_name(data["obj"])
if obj is None:
# obj was just created during this step and didn't existed before.
return
bpy.msgbus.clear_by_owner(obj)
@staticmethod
def commit_link_element(data: OperationData) -> None:
@@ -364,8 +367,10 @@ class IfcStore:
del IfcStore.id_map[data["id"]]
if "guid" in data:
del IfcStore.guid_map[data["guid"]]
# Note: msgbus subscriptions are cleared globally during
# rebuild_element_maps which runs after every undo/redo.
obj = IfcStore.get_object_by_name(data["obj"])
# obj might be removed after unlink.
if not obj:
bpy.msgbus.clear_by_owner(obj)
@staticmethod
def unlink_element(
+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
+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
+1 -1
View File
@@ -230,7 +230,7 @@ class BcfTopic(PropertyGroup):
def get_related_topics(self: "BCFProperties", context: bpy.types.Context) -> list[tuple[str, str, str]]:
global RELATED_TOPICS_ENUM_ITEMS # ty: ignore[unresolved-global]
global RELATED_TOPICS_ENUM_ITEMS
props = self
active_topic = props.active_topic
active_related_topics = active_topic.related_topics.keys()
@@ -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)
+4 -4
View File
@@ -46,26 +46,26 @@ def get_libraries(self, context):
def get_namespaces(self, context):
global NAMESPACES_ENUM_ITEMS # ty: ignore[unresolved-global]
global NAMESPACES_ENUM_ITEMS
NAMESPACES_ENUM_ITEMS = [(uri, f"{alias}: {uri}", "") for alias, uri in BrickStore.namespaces]
return NAMESPACES_ENUM_ITEMS
def get_brick_entity_classes(self, context):
global ENTITY_CLASSES_ENUM_ITEMS # ty: ignore[unresolved-global]
global ENTITY_CLASSES_ENUM_ITEMS
entity = self.brick_entity_create_type
ENTITY_CLASSES_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.entity_classes[entity]]
return ENTITY_CLASSES_ENUM_ITEMS
def get_brick_roots(self, context):
global BRICK_ROOTS_ENUM_ITEMS # ty: ignore[unresolved-global]
global BRICK_ROOTS_ENUM_ITEMS
BRICK_ROOTS_ENUM_ITEMS = [(root, root, "") for root in BrickStore.root_classes]
return BRICK_ROOTS_ENUM_ITEMS
def get_brick_relations(self, context):
global BRICK_RELATIONS_ENUM_ITEMS # ty: ignore[unresolved-global]
global BRICK_RELATIONS_ENUM_ITEMS
BRICK_RELATIONS_ENUM_ITEMS = [(uri, uri.split("#")[-1], "") for uri in BrickStore.relationships]
for relation in BrickschemaData.data["active_relations"]:
if relation["predicate_name"] == "label":
@@ -37,7 +37,6 @@ messages = {
class CadTrimExtend(bpy.types.Operator):
bl_idname = "bim.cad_trim_extend"
bl_label = "CAD Trim / Extend"
bl_description = "Extends/reduces element to 3D cursor"
@classmethod
def poll(cls, context):
@@ -83,7 +82,6 @@ class CadTrimExtend(bpy.types.Operator):
class CadMitre(bpy.types.Operator):
bl_idname = "bim.cad_mitre"
bl_label = "CAD Mitre"
bl_description = "Joins two non-parallel paths at their intersection"
@classmethod
def poll(cls, context):
+18 -56
View File
@@ -106,37 +106,23 @@ class CadTool(WorkSpaceTool):
)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Rectangle", "S_R", bpy.ops.bim.add_rectangle.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Circle", "S_C", bpy.ops.bim.add_ifccircle.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.set_arc_index.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Reset Vertex", "S_X", bpy.ops.bim.reset_vertex.__doc__, ui_context)
elif (
isinstance(data, tool.Geometry.TYPES_WITH_MESH_PROPERTIES)
@@ -146,21 +132,15 @@ class CadTool(WorkSpaceTool):
layout, "Edit Axis", "bim.edit_extrusion_axis", "bim.disable_editing_extrusion_axis", ui_context
)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.cad_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
else:
if (
@@ -188,37 +168,19 @@ class CadTool(WorkSpaceTool):
add_layout_hotkey_operator(row, "Set Gable Roof Angle", "S_R", "Set Gable Roof Angle", ui_context)
row = layout.row(align=True)
add_layout_hotkey_operator(
row, "Extend", "S_E", bpy.ops.bim.cad_trim_extend.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Extend", "S_E", "Extends/reduces element to 3D cursor", ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Join", "S_T", bpy.ops.bim.cad_mitre.__doc__.split("\n", 1)[1].strip(), ui_context
row, "Join", "S_T", "Joins two non-parallel paths at their intersection", ui_context
)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Fillet", "S_F", bpy.ops.bim.add_ifcarcindex_fillet.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__.split("\n", 1)[1].strip(), ui_context
)
add_layout_hotkey_operator(row, "Offset", "S_O", bpy.ops.bim.cad_offset.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row,
"2-Point Arc",
"S_C",
bpy.ops.bim.cad_arc_from_2_points.__doc__.split("\n", 1)[1].strip(),
ui_context,
)
add_layout_hotkey_operator(row, "2-Point Arc", "S_C", bpy.ops.bim.cad_arc_from_2_points.__doc__, ui_context)
row = row if ui_context == "TOOL_HEADER" else layout.row(align=True)
add_layout_hotkey_operator(
row,
"3-Point Arc",
"S_V",
bpy.ops.bim.cad_arc_from_3_points.__doc__.split("\n", 1)[1].strip(),
ui_context,
)
add_layout_hotkey_operator(row, "3-Point Arc", "S_V", bpy.ops.bim.cad_arc_from_3_points.__doc__, ui_context)
class CadHotkey(bpy.types.Operator):
+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",
@@ -1787,7 +1787,7 @@ class CutDecorator:
# Handle both old float64 and new float32 checksums for version compatibility
rot_checksum_bytes: bytes = eval(DecoratorData.camera_rotation_checksum)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9).reshape(3, 3)
rot_check = tool.Blender.np_frombuffer_legacy(rot_checksum_bytes, 9)
rot_real = tool.Blender.np_array_legacy(obj.matrix_world.to_3x3())
rot_dot = np.dot(rot_check, rot_real.T)
angle_rad = np.arccos(np.clip((np.trace(rot_dot) - 1) / 2, -1, 1))
@@ -1285,7 +1285,7 @@ class SnapManager:
continue
coords = np.empty(vertex_count * 3, dtype=np.float32)
mesh.vertices.foreach_get("co", coords)
mesh.vertices.foreach_get("co", coords) # type: ignore[arg-type]
coords = coords.reshape(-1, 3)
matrix = np.array(obj_eval.matrix_world, dtype=np.float32)
@@ -456,8 +456,7 @@ def format_distance(
tx_dist = fmt % d_cm
else:
assert f"Unexpected unit_system - '{unit_system}'."
# tx_dist = fmt % value
tx_dist = fmt % value
return tx_dist
@@ -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,
@@ -1426,7 +1426,6 @@ class CreateDrawing(bpy.types.Operator):
"/Pset_.*Common/.Status",
"EPset_Status.Status",
"EPset_Status.UserDefinedStatus",
"Material.Name",
]
group = root.find("{http://www.w3.org/2000/svg}g")
@@ -2322,14 +2321,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,
@@ -3306,8 +3305,9 @@ class AddTextLiteral(bpy.types.Operator):
attr.data_type = "string"
attr.string_value = literal_attr_values[attr_name]
literal_props.align_vertical = "bottom"
literal_props.align_horizontal = "left"
box_alignment_mask = [False] * 9
box_alignment_mask[6] = True # bottom_left box_alignment
literal_props.box_alignment = box_alignment_mask
return {"FINISHED"}
@@ -3365,55 +3365,57 @@ class OrderTextLiteralDown(bpy.types.Operator):
return {"FINISHED"}
class AssignSelectedObjectAsProduct(bpy.types.Operator, tool.Ifc.Operator):
# Ifc Operator is unnecessary, because suboperator is handling IFC changes.
class AssignSelectedObjectAsProduct(bpy.types.Operator):
bl_idname = "bim.assign_selected_as_product"
bl_label = "Assign Selected Object As Product"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if len(context.selected_objects) < 2:
cls.poll_message_set("At least 2 objects need to be selected")
if len(context.selected_objects) != 2:
cls.poll_message_set("2 objects need to be selected")
return False
return True
def _execute(self, context):
def execute(self, context):
assert bpy.context.view_layer
objs = context.selected_objects[:]
ifc_objs = [(o, tool.Ifc.get_entity(o)) for o in objs if tool.Ifc.get_entity(o)]
obj1, obj2 = objs
element1 = tool.Ifc.get_entity(obj1)
element2 = tool.Ifc.get_entity(obj2)
assert element1 and element2
annotations = [(o, e) for o, e in ifc_objs if e.is_a("IfcAnnotation")]
non_annotations = [(o, e) for o, e in ifc_objs if not e.is_a("IfcAnnotation")]
# Check if at least one object is an IfcAnnotation
is_annotation1 = element1.is_a("IfcAnnotation")
is_annotation2 = element2.is_a("IfcAnnotation")
if not annotations:
self.report({"ERROR"}, "At least one selected object must be an IfcAnnotation.")
if not (is_annotation1 or is_annotation2):
self.report({"ERROR"}, "At least one of the selected objects must be IfcAnnotation.")
return {"CANCELLED"}
if len(non_annotations) == 1:
# One product, one or more annotations — assign all annotations to the product.
product = non_annotations[0][1]
elif len(non_annotations) == 0 and len(annotations) == 2:
# Both objects are annotations — use the non-active one as the relating product.
# If both are annotations, use the currently active object as relating product
if is_annotation1 and is_annotation2:
active_obj = context.active_object
if annotations[0][0] == active_obj:
annotation_obj, annotation = annotations[0]
product = annotations[1][1]
if active_obj == obj1:
other_selected_object = obj1
bpy.context.view_layer.objects.active = obj2
else:
annotation_obj, annotation = annotations[1]
product = annotations[0][1]
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
tool.Blender.update_viewport()
return
other_selected_object = obj2
bpy.context.view_layer.objects.active = obj1
# If only one is an annotation, make it the active object
elif is_annotation1:
other_selected_object = obj2
bpy.context.view_layer.objects.active = obj1
else:
self.report(
{"ERROR"},
"Select exactly one product object and one or more IfcAnnotation objects.",
)
return {"CANCELLED"}
other_selected_object = obj1
bpy.context.view_layer.objects.active = obj2
for annotation_obj, _ in annotations:
core.edit_assigned_product(tool.Ifc, tool.Drawing, obj=annotation_obj, product=product)
tool.Blender.update_viewport()
assert (active_obj := context.active_object)
props = tool.Drawing.get_object_assigned_product_props(active_obj)
props.relating_product = other_selected_object
bpy.ops.bim.edit_assigned_product()
return {"FINISHED"}
class EditAssignedProduct(bpy.types.Operator, tool.Ifc.Operator):
@@ -3635,12 +3637,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
@@ -3883,7 +3887,8 @@ class AddReferenceImage(bpy.types.Operator, tool.Ifc.Operator, ImportHelper):
image_filepath = Path(tool.Ifc.get_uri(self.filepath, use_relative_path=self.use_relative_path))
ifc_file = tool.Ifc.get()
image = load_image(abs_path.name, str(abs_path.parent), check_existing=False)
params = {"check_existing": False}
image = load_image(abs_path.name, str(abs_path.parent), **params)
mesh = bpy.data.meshes.new(image_filepath.stem)
obj = bpy.data.objects.new(image_filepath.stem, mesh)
@@ -4173,7 +4178,10 @@ class SelectSimilarTextLiteralValue(bpy.types.Operator):
should_select = True
break
elif self.attribute_type == "box_alignment":
if literal.get_box_alignment() == self.literal_value:
box_alignment_attr = next(
(attr for attr in literal.attributes if attr.name == "BoxAlignment"), None
)
if box_alignment_attr and box_alignment_attr.string_value == self.literal_value:
should_select = True
break
+46 -38
View File
@@ -27,6 +27,7 @@ import ifcopenshell.api.pset
import ifcopenshell.util.element
from bpy.props import (
BoolProperty,
BoolVectorProperty,
CollectionProperty,
EnumProperty,
FloatProperty,
@@ -672,6 +673,20 @@ class BIMCameraProperties(PropertyGroup):
return ortho_scale, aspect_ratio
DEFAULT_BOX_ALIGNMENT = [False] * 6 + [True] + [False] * 2
BOX_ALIGNMENT_POSITIONS = [
"top-left",
"top-middle",
"top-right",
"middle-left",
"center",
"middle-right",
"bottom-left",
"bottom-middle",
"bottom-right",
]
class ElementValueRow(PropertyGroup):
"""Represents a single element value row with category, key, and formatted value"""
@@ -774,38 +789,40 @@ def get_category_items_with_counts(self, context):
class LiteralProps(PropertyGroup):
attributes: CollectionProperty(name="Attributes", type=Attribute)
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
align_horizontal: EnumProperty(
items=[
("left", "Left", "", "ALIGN_LEFT", 0),
("middle", "Middle", "", "ALIGN_CENTER", 1),
("right", "Right", "", "ALIGN_RIGHT", 2),
],
default="left",
name="Horizontal Alignment",
)
align_vertical: EnumProperty(
items=[
("top", "Top", "", "ALIGN_TOP", 0),
("middle", "Middle", "", "ALIGN_MIDDLE", 1),
("bottom", "Bottom", "", "ALIGN_BOTTOM", 2),
],
default="middle",
name="Vertical Alignment",
)
def set_box_alignment(self, new_value):
markers = new_value.count(True)
if not markers:
return
def get_box_alignment(self) -> str:
alignment = self.align_vertical + "-" + self.align_horizontal
if alignment == "middle-middle":
alignment = "center"
return alignment
if markers > 1:
prev_value = self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
# looking for the first value changed to positive
first_changed_value = next((i for i in range(9) if new_value[i] and new_value[i] != prev_value[i]), None)
# if nothing have changed we just keep the previous value
if first_changed_value is None:
return
new_value = [False] * 9
new_value[first_changed_value] = True
self["box_alignment"] = new_value
position_string = BOX_ALIGNMENT_POSITIONS[next(i for i in range(9) if new_value[i])]
self.attributes["BoxAlignment"].set_value(position_string)
def get_box_alignment(self):
return self.get("box_alignment", DEFAULT_BOX_ALIGNMENT)
attributes: CollectionProperty(name="Attributes", type=Attribute)
box_alignment: BoolVectorProperty(
name="Box alignment", size=9, set=set_box_alignment, get=get_box_alignment, default=DEFAULT_BOX_ALIGNMENT
)
ifc_definition_id: IntProperty(name="IFC definition ID", default=0)
def get_literal_edited_data(self) -> dict[str, str]:
text_data = {
"CurrentValue": self.attributes["Literal"].string_value,
"Literal": self.attributes["Literal"].string_value,
"BoxAlignment": self.get_box_alignment(),
"BoxAlignment": self.attributes["BoxAlignment"].string_value,
}
return text_data
@@ -843,30 +860,23 @@ class LiteralProps(PropertyGroup):
if TYPE_CHECKING:
attributes: bpy.types.bpy_prop_collection_idprop[Attribute]
value: str
box_alignment: tuple[bool, bool, bool, bool, bool, bool, bool, bool, bool]
ifc_definition_id: int
align_horizontal: str
align_vertical: str
element_value_rows: bpy.types.bpy_prop_collection_idprop[ElementValueRow]
category_for_adding: str
def update_text_alignment(self, context):
for literal_props in self.literals:
literal_props.align_horizontal = self.align_horizontal
literal_props.align_vertical = self.align_vertical
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.",
)
@@ -889,7 +899,6 @@ class BIMTextProperties(PropertyGroup):
],
default="left",
name="Horizontal Alignment",
update=update_text_alignment,
)
align_vertical: EnumProperty(
items=[
@@ -899,7 +908,6 @@ class BIMTextProperties(PropertyGroup):
],
default="middle",
name="Vertical Alignment",
update=update_text_alignment,
)
if TYPE_CHECKING:
@@ -366,6 +366,9 @@ class BaseLinesShader(BaseShader):
}
"""
def __init__(self, gap_size=16):
super().__init__(gap_size=gap_size)
def glenable(self):
super().glenable()
+28 -4
View File
@@ -781,10 +781,33 @@ class BIM_PT_text(Panel):
if other_attributes:
bonsai.bim.helper.draw_attributes(other_attributes, box)
row = box.row()
row.label(text="Alignment")
row.prop(literal_props, "align_horizontal", text="", expand=True)
row.prop(literal_props, "align_vertical", text="", expand=True)
row = box.row(align=True)
cols = [row.column(align=True) for j in range(3)]
for j in range(9):
cols[j % 3].prop(
literal_props,
"box_alignment",
text="",
index=j,
icon="RADIOBUT_ON" if literal_props.box_alignment[j] else "RADIOBUT_OFF",
)
col = row.column(align=True)
alignment_label_row = col.row(align=True)
alignment_label_row.label(text=" Text box alignment:")
box_alignment_value = (
literal_props.attributes[
next(
(idx for idx, attr in enumerate(literal_props.attributes) if attr.name == "BoxAlignment"),
-1,
)
].string_value
if any(attr.name == "BoxAlignment" for attr in literal_props.attributes)
else "N/A"
)
col.label(text=f" {box_alignment_value}")
def draw(self, context):
obj = context.active_object
@@ -816,6 +839,7 @@ class BIM_PT_text(Panel):
for i, literal_data in enumerate(text_data["Literals"]):
box = self.layout.box()
box.label(text=f"Literal[{i}]:")
# Combine both approaches: clickable attributes from PR #7292 and display from PR #7106
for attribute in literal_data:
@@ -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,13 +1060,13 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
def poll(cls, context) -> bool:
# Match `outliner.delete` poll for consistency.
# `outliner.delete` just checks `area.type` == `OUTLINER`.
poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument]
poll = bpy.ops.outliner.delete.poll()
if poll:
return True
cls.poll_message_set("Only available from Outliner.")
return False
def execute(self, context): # ty:ignore[override-of-final-method]
def execute(self, context):
if len(getattr(context, "selected_ids", [])) == 0:
return {"FINISHED"}
@@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
def poll(cls, context) -> bool:
# Match `object.duplicate_move` poll for consistency.
# `object.duplicate_move` poll checks for OBJECT mode.
poll = bpy.ops.object.duplicate_move.poll() # 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:
@@ -2289,7 +2289,7 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator):
elif obj in pprops.clipping_planes_objs:
self.report({"ERROR"}, "Clipping planes cannot be edited")
elif element:
if not obj.data or obj.type not in ("MESH", "CURVE"):
if not obj.data:
self.report({"INFO"}, "No geometry to edit")
elif tool.Geometry.is_locked(element):
self.report({"ERROR"}, lock_error_message(obj.name))
@@ -139,9 +139,7 @@ def update_local_coordinates(self: "BIMGeoreferenceProperties", context: bpy.typ
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
*local_coordinates,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -164,9 +162,7 @@ def update_map_coordinates(self: "BIMGeoreferenceProperties", context: bpy.types
tool.Georeference.set_coordinates(
"blender",
ifcopenshell.util.geolocation.enh2xyz(
local_coordinates[0],
local_coordinates[1],
local_coordinates[2],
*local_coordinates,
float(props.blender_offset_x),
float(props.blender_offset_y),
float(props.blender_offset_z),
@@ -271,8 +267,6 @@ class BIMGeoreferenceProperties(PropertyGroup):
x_axis_ordinate: str
x_axis_is_null: bool
model_is_georeferenced: bool
model_crs: str
model_origin: str
model_origin_si: str
model_project_north: str
+1 -1
View File
@@ -27,7 +27,7 @@ from bonsai.bim.prop import StrProperty
class BIMCityJsonProperties(PropertyGroup):
def get_lods(self, context):
global LODS_ENUM_ITEMS # ty: ignore[unresolved-global]
global LODS_ENUM_ITEMS
LODS_ENUM_ITEMS = [(item.name, "LOD" + item.name, "Level of Detail " + item.name) for item in self.lods]
return LODS_ENUM_ITEMS
@@ -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):
+17 -29
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import bpy
import pyradiance
from . import export, ies, list, material, prepare, prop, render, solar, ui
from . import list, operator, prop, ui
def get_pyradiance_path():
@@ -31,43 +31,31 @@ def get_pyradiance_path():
classes = (
export.ExportOBJ,
solar.ImportLatLong,
solar.ImportTrueNorth,
solar.MoveSunPathTo3DCursor,
render.RadianceRender,
render.FalseColorRadiance,
render.RADIANCE_OT_select_camera,
solar.ViewFromSun,
solar.LightPickCoordinates,
solar.LightSetTimeToNow,
material.RefreshIFCMaterials,
material.UnmapMaterial,
material.RADIANCE_OT_export_material_mappings,
material.RADIANCE_OT_import_material_mappings,
material.RADIANCE_OT_open_spectraldb,
prepare.PrepareRadianceScene,
ies.AddIESLight,
ies.RemoveIESLight,
export.CleanupRadianceFiles,
operator.ExportOBJ,
operator.ImportLatLong,
operator.ImportTrueNorth,
operator.MoveSunPathTo3DCursor,
operator.RadianceRender,
operator.ViewFromSun,
operator.LightPickCoordinates,
operator.LightSetTimeToNow,
operator.RefreshIFCMaterials,
operator.UnmapMaterial,
operator.RADIANCE_OT_select_camera,
operator.RADIANCE_OT_export_material_mappings,
operator.RADIANCE_OT_import_material_mappings,
operator.RADIANCE_OT_open_spectraldb,
prop.RadianceMaterial,
prop.IESLight,
prop.BIMSolarProperties,
prop.RadianceExporterProperties,
ui.BIM_PT_radiance_exporter,
ui.BIM_PT_radiance_scene_setup,
ui.BIM_PT_radiance_materials,
ui.BIM_PT_radiance_lighting,
ui.BIM_PT_radiance_render_settings,
ui.BIM_PT_radiance_pipeline,
ui.BIM_PT_solar,
list.MATERIAL_UL_radiance_materials,
list.MATERIAL_UL_ies_lights,
)
def register():
bpy.types.Scene.BIMRadianceExporterProperties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties)
if pyradiance:
@@ -80,5 +68,5 @@ def register():
def unregister():
del bpy.types.Scene.BIMRadianceExporterProperties
del bpy.types.Scene.BIMRadianceExporeterProperies
del bpy.types.Scene.BIMSolarProperties
@@ -1,488 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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 multiprocessing
import os
from pathlib import Path
import bpy
import ifcopenshell
import ifcopenshell.geom
import bonsai.tool as tool
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def _get_geom_settings(self):
"""Create standard geometry and serializer settings for OBJ export."""
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
return settings, serializer_settings
def _get_exportable_elements(self, ifc_file, filter_visibility=True):
"""Get the list of elements to export from an IFC file."""
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
if not filter_visibility:
return elements
# Filter by visibility in Blender.
# We use hide_get() (user-toggled eye icon) instead of visible_get()
# because visible_get() also considers collection/view-layer visibility
# which incorrectly excludes objects in linked aggregate sub-collections.
visible_elements = []
for element in elements:
blender_obj = tool.Ifc.get_object(element)
if blender_obj is None:
# No Blender object (linked aggregate copies, or not yet represented)
# Include by default — the geometry exists in the IFC file
visible_elements.append(element)
continue
if not blender_obj.hide_get():
visible_elements.append(element)
else:
print(f"Skipping hidden element: {element.GlobalId if hasattr(element, 'GlobalId') else element.id()}")
return visible_elements
def _export_ifc_to_obj(self, ifc_file, obj_path, mtl_path, settings, serializer_settings, elements):
"""Export elements from an IFC file to OBJ format. Returns collected material names."""
materials_collected = []
serialiser = ifcopenshell.geom.serializers.obj(obj_path, mtl_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
print(f"Exporting {len(elements)} elements to {obj_path}")
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
for material in shape.geometry.materials:
materials_collected.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
return materials_collected
def _sync_moved_object_placements(self):
"""Sync Blender object positions to IFC ObjectPlacements for all moved objects.
This is critical for linked aggregate copies: their IFC ObjectPlacements
are initially copies of the original's placement. After the user moves them
in Blender, the IFC placements are stale until explicitly synced. Without
this, the iterator with use-world-coords=True exports all copies at the
original position.
"""
import bonsai.core.geometry as core_geometry
synced = 0
for obj in bpy.data.objects:
element = tool.Ifc.get_entity(obj)
if element is None:
continue
if not element.is_a("IfcProduct"):
continue
try:
if tool.Ifc.is_moved(obj):
core_geometry.edit_object_placement(
ifc=tool.Ifc,
geometry=tool.Geometry,
surveyor=tool.Surveyor,
obj=obj,
apply_scale=False,
)
synced += 1
except Exception as e:
print(f"Could not sync placement for {obj.name}: {e}")
if synced:
print(f"Synced {synced} moved object placement(s) to IFC before export")
def _export_collection_instances_obj(self, context, output_dir):
"""Export Blender collection instances as per-parent OBJ files.
Collection instances (empties with instance_type='COLLECTION') are purely
Blender constructs they don't exist in the IFC file. The ifcopenshell
iterator ignores them entirely, so we must export their geometry via
Blender's depsgraph which evaluates all instances with correct world transforms.
Each collection instance parent gets its own OBJ file because obj2mesh
cannot handle all instances in a single file ("too many patch triangles").
"""
depsgraph = context.evaluated_depsgraph_get()
# Find which objects are collection instance parents (visible, not hidden)
# Skip collections that belong to linked IFC models — those are already
# exported by _export_linked_models() via the IFC serializer.
instance_parents = set()
for obj in bpy.data.objects:
if obj.instance_type == "COLLECTION" and obj.instance_collection is not None:
if not obj.visible_get():
continue
# Check if this collection contains linked IFC objects
coll = obj.instance_collection
is_linked_ifc = any("guids" in child for child in coll.all_objects if child.type == "MESH")
if is_linked_ifc:
print(f"Skipping collection instance '{obj.name}' (linked IFC model, exported via IFC serializer)")
continue
instance_parents.add(obj.name)
if not instance_parents:
return
print(f"Found {len(instance_parents)} collection instance(s) to export")
# Export one OBJ per collection instance parent
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
total_meshes = 0
for parent_name in sorted(instance_parents):
obj_path = os.path.join(output_dir, f"instance_{parent_name}.obj")
vert_offset = 0
mesh_count = 0
with open(obj_path, "w") as f:
f.write(f"# Collection instance geometry for {parent_name}\n")
f.write("usemtl white\n\n")
for dep_inst in depsgraph.object_instances:
if not dep_inst.is_instance:
continue
if dep_inst.parent is None:
continue
if dep_inst.parent.original.name != parent_name:
continue
eval_obj = dep_inst.object
if eval_obj.type != "MESH":
continue
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = dep_inst.matrix_world
f.write(f"g obj_{mesh_count}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
continue
# Identity matrix — geometry is already at world coordinates
linked_model_exports.append((obj_path, "", identity))
total_meshes += mesh_count
print(f" {parent_name}: {mesh_count} meshes, {vert_offset} vertices")
if total_meshes > 0:
print(f"Exported {total_meshes} instanced meshes across {len(linked_model_exports)} file(s)")
def _export_non_ifc_meshes(self, context, output_dir):
"""Export visible Blender mesh objects that have no IFC entity.
These are plain Blender geometry (e.g. manually added planes, cubes)
that don't exist in any IFC file. They are skipped by the IFC iterator
and by the collection instance exporter, so we handle them separately.
"""
non_ifc_meshes = []
for obj in bpy.data.objects:
if obj.type != "MESH":
continue
if not obj.visible_get():
continue
# Skip objects that have an IFC entity (handled by main/linked IFC export)
if tool.Ifc.get_entity(obj) is not None:
continue
# Skip objects inside instanced collections (handled by collection instance export)
if any(col.library for col in obj.users_collection):
continue
# Skip linked IFC element objects (have "guids" custom prop)
if "guids" in obj:
continue
non_ifc_meshes.append(obj)
if not non_ifc_meshes:
return
obj_path = os.path.join(output_dir, "blender_meshes.obj")
vert_offset = 0
mesh_count = 0
identity = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
depsgraph = context.evaluated_depsgraph_get()
with open(obj_path, "w") as f:
f.write("# Non-IFC Blender mesh geometry\n")
f.write("usemtl white\n\n")
for obj in non_ifc_meshes:
eval_obj = obj.evaluated_get(depsgraph)
try:
mesh = eval_obj.to_mesh()
except RuntimeError:
continue
if mesh is None:
continue
matrix = obj.matrix_world
f.write(f"g {obj.name}\n")
for v in mesh.vertices:
co = matrix @ v.co
f.write(f"v {co.x} {co.y} {co.z}\n")
mesh.calc_loop_triangles()
for tri in mesh.loop_triangles:
i0 = vert_offset + tri.vertices[0] + 1
i1 = vert_offset + tri.vertices[1] + 1
i2 = vert_offset + tri.vertices[2] + 1
f.write(f"f {i0} {i1} {i2}\n")
vert_offset += len(mesh.vertices)
mesh_count += 1
eval_obj.to_mesh_clear()
if mesh_count == 0:
try:
os.remove(obj_path)
except OSError:
pass
return
linked_model_exports.append((obj_path, "", identity))
print(f"Exported {mesh_count} non-IFC Blender mesh(es) ({vert_offset} vertices)")
def execute(self, context):
ifc_materials.clear()
linked_model_exports.clear()
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
props.is_exporting = True
# Sync all moved Blender object positions to IFC before export.
self._sync_moved_object_placements()
settings, serializer_settings = self._get_geom_settings()
ifc_file = tool.Ifc.get()
# --- Export main model ---
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
visible_elements = self._get_exportable_elements(ifc_file, filter_visibility=True)
mats = self._export_ifc_to_obj(
ifc_file, obj_file_path, mtl_file_path, settings, serializer_settings, visible_elements
)
ifc_materials.extend(mats)
self.report({"INFO"}, f"Exported main model OBJ to: {obj_file_path}")
# --- Export linked models (external IFC files) ---
self._export_linked_models(ifc_file, output_dir, settings, serializer_settings)
# --- Export collection instances (Blender-level linked copies) ---
self._export_collection_instances_obj(context, output_dir)
# --- Export non-IFC Blender meshes (plain geometry with no IFC entity) ---
self._export_non_ifc_meshes(context, output_dir)
props.is_exporting = False
total_linked = len(linked_model_exports)
if total_linked:
self.report({"INFO"}, f"Also exported {total_linked} linked/instanced model(s)")
return {"FINISHED"}
def _export_linked_models(self, main_ifc_file, output_dir, settings, serializer_settings):
"""Detect and export all linked IFC models."""
try:
project_props = tool.Project.get_project_props()
except Exception:
print("Could not access project properties for linked models")
return
for idx, link in enumerate(project_props.links):
if not link.is_loaded:
print(f"Skipping linked model '{link.name}' (not loaded)")
continue
# Check if the link's empty handle is hidden in Blender
try:
link_empty = tool.Project.get_link_empty_handle(link)
if link_empty is not None and not link_empty.visible_get():
print(f"Skipping linked model '{link.name}' (hidden in viewport)")
continue
except Exception:
pass # If we can't check visibility, export anyway
try:
filepath = Path(tool.Ifc.resolve_uri(link.filepath))
except Exception as e:
print(f"Could not resolve path for linked model '{link.name}': {e}")
continue
if not filepath.exists():
print(f"Linked IFC file not found: {filepath}")
continue
print(f"Exporting linked model {idx}: {filepath.name}")
try:
linked_ifc = ifcopenshell.open(str(filepath))
except Exception as e:
print(f" Failed to open linked IFC: {e}")
continue
link_obj_path = os.path.join(output_dir, f"linked_{idx}.obj")
link_mtl_path = os.path.join(output_dir, f"linked_{idx}.mtl")
# For linked models we don't filter by Blender visibility
# (their objects are collection instances, not individually tracked)
elements = self._get_exportable_elements(linked_ifc, filter_visibility=False)
if not elements:
print(f" No exportable elements found in linked model")
continue
mats = self._export_ifc_to_obj(
linked_ifc, link_obj_path, link_mtl_path, settings, serializer_settings, elements
)
ifc_materials.extend(mats)
# Get the link transformation matrix
try:
link_matrix = tool.Project.calculate_link_matrix(link)
matrix_list = [list(row) for row in link_matrix]
except Exception as e:
print(f" Could not calculate link matrix: {e}, using identity")
matrix_list = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
linked_model_exports.append((link_obj_path, link_mtl_path, matrix_list))
print(f" Exported {len(elements)} elements from linked model '{link.name}'")
class CleanupRadianceFiles(bpy.types.Operator):
"""Delete all generated Radiance files from the output directory"""
bl_idname = "radiance.cleanup_files"
bl_label = "Cleanup Radiance Files"
bl_description = "Remove all generated files (OBJ, MTL, RTM, RAD, HDR, TIFF, DAT) from the output directory"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
import bonsai.bim.module.light.shared as shared
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
if not os.path.isdir(output_dir):
self.report({"WARNING"}, f"Output directory does not exist: {output_dir}")
return {"CANCELLED"}
cleanup_patterns = (
"model.obj",
"model.mtl",
"model.rtm",
"sky.rad",
"materials.rad",
"scene.rad",
"ascene.oct",
"mascene.oct",
"ascene.amb",
)
cleanup_extensions = (".hdr", ".tiff", ".rad", ".dat")
cleanup_prefixes = ("instance_", "linked_", "blender_meshes")
removed = 0
for filename in os.listdir(output_dir):
filepath = os.path.join(output_dir, filename)
if not os.path.isfile(filepath):
continue
is_generated = (
filename in cleanup_patterns
or os.path.splitext(filename)[1].lower() in cleanup_extensions
or any(filename.startswith(p) for p in cleanup_prefixes)
)
if is_generated:
try:
os.remove(filepath)
removed += 1
except OSError as e:
print(f"Failed to remove {filepath}: {e}")
# Reset the global scene reference
shared.scene = None
self.report({"INFO"}, f"Cleaned up {removed} generated files from {output_dir}")
return {"FINISHED"}
-80
View File
@@ -1,80 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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/>.
from pathlib import Path
import bpy
from bpy_extras.io_utils import ImportHelper
import bonsai.tool as tool
class AddIESLight(bpy.types.Operator, ImportHelper):
"""Upload and add an IES light fixture to the scene"""
bl_idname = "radiance.add_ies_light"
bl_label = "Add IES Light"
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ies"
filter_glob: bpy.props.StringProperty(default="*.ies;*.IES", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
# Create new IES light entry
ies_light = props.ies_lights.add()
# Store as relative path if the blend file is saved
if bpy.data.filepath:
ies_light.ies_file_path = bpy.path.relpath(self.filepath)
else:
ies_light.ies_file_path = self.filepath
ies_light.rotation_z = 0.0
ies_light.is_enabled = True
# Set as active
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, f"Added IES light: {Path(self.filepath).name}")
return {"FINISHED"}
class RemoveIESLight(bpy.types.Operator):
"""Remove an IES light fixture mapping"""
bl_idname = "radiance.remove_ies_light"
bl_label = "Remove IES Light"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
if 0 <= self.index < len(props.ies_lights):
props.ies_lights.remove(self.index)
# Adjust active index if needed
if props.active_ies_light_index >= len(props.ies_lights):
props.active_ies_light_index = len(props.ies_lights) - 1
self.report({"INFO"}, "IES light removed")
return {"FINISHED"}
self.report({"WARNING"}, "Invalid IES light index")
return {"CANCELLED"}
+5 -40
View File
@@ -18,18 +18,21 @@
from __future__ import annotations
from pathlib import Path
import json
import os
from typing import TYPE_CHECKING
import bpy
if TYPE_CHECKING:
from bonsai.bim.module.light.prop import (
IESLight,
RadianceExporterProperties,
RadianceMaterial,
)
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
def draw_item(
@@ -61,41 +64,3 @@ class MATERIAL_UL_radiance_materials(bpy.types.UIList):
op.material_index = index
else:
row.label(text="Not Mapped (White)")
class MATERIAL_UL_ies_lights(bpy.types.UIList):
"""UIList for displaying IES light fixtures."""
def draw_item(
self,
context,
layout: bpy.types.UILayout,
data: RadianceExporterProperties,
item: IESLight,
icon,
active_data,
active_propname,
index,
) -> None:
if self.layout_type in {"DEFAULT", "COMPACT"}:
row = layout.row(align=True)
# Enable/disable checkbox (visible toggle)
row.prop(item, "is_enabled", text="", emboss=True)
# IES file name
if item.ies_file_path:
filename = Path(item.ies_file_path).name
row.label(text=filename, icon="FILE")
else:
row.label(text="(No file selected)", icon="ERROR")
# Target: collection or single object
if item.use_collection:
row.prop(item, "target_collection", text="", icon="OUTLINER_COLLECTION", emboss=False)
else:
row.prop(item, "target_object", text="", emboss=False)
# Remove button (X icon - negative action)
op = row.operator("radiance.remove_ies_light", text="", icon="X")
op.index = index
@@ -1,136 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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 json
import webbrowser
import bpy
from bpy_extras.io_utils import ExportHelper, ImportHelper
import bonsai.tool as tool
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
cls.poll_message_set("No IFC file loaded in Bonsai.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file = tool.Ifc.get()
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color
color = (1.0, 1.0, 1.0) # Default white
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
material = props.add_material_mapping(style_id, style_name)
material.color = color
material.category = ""
material.subcategory = ""
material.is_mapped = False
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
+710 -38
View File
@@ -16,44 +16,716 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
"""Thin re-export module for backward compatibility.
import json
import math
import multiprocessing
import os
import time
import webbrowser
from datetime import datetime
from math import radians
from pathlib import Path
from typing import TYPE_CHECKING, Union
All operator classes are defined in their respective submodules:
- export.py: ExportOBJ, CleanupRadianceFiles
- prepare.py: PrepareRadianceScene
- render.py: RadianceRender, FalseColorRadiance, RADIANCE_OT_select_camera
- solar.py: ImportTrueNorth, ImportLatLong, MoveSunPathTo3DCursor,
ViewFromSun, LightPickCoordinates, LightSetTimeToNow
- material.py: RefreshIFCMaterials, UnmapMaterial,
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb
- ies.py: AddIESLight, RemoveIESLight
import bpy
import ifcopenshell
import ifcopenshell.geom
import ifcopenshell.ifcopenshell_wrapper as W
import ifcopenshell.util.geolocation
import pyradiance as pr
import requests
from bpy_extras.io_utils import ExportHelper, ImportHelper
from mathutils import Vector
EnumPropertySearch and SetEnumProperty are provided by the global
bonsai.bim.operator module (bl_idname "bim.enum_property_search").
"""
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.export import CleanupRadianceFiles, ExportOBJ # noqa: F401
from bonsai.bim.module.light.ies import AddIESLight, RemoveIESLight # noqa: F401
from bonsai.bim.module.light.material import ( # noqa: F401
RADIANCE_OT_export_material_mappings,
RADIANCE_OT_import_material_mappings,
RADIANCE_OT_open_spectraldb,
RefreshIFCMaterials,
UnmapMaterial,
)
from bonsai.bim.module.light.prepare import PrepareRadianceScene # noqa: F401
from bonsai.bim.module.light.render import ( # noqa: F401
FalseColorRadiance,
RADIANCE_OT_select_camera,
RadianceRender,
)
from bonsai.bim.module.light.solar import ( # noqa: F401
ImportLatLong,
ImportTrueNorth,
LightPickCoordinates,
LightSetTimeToNow,
MoveSunPathTo3DCursor,
ViewFromSun,
)
ifc_materials = []
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb = json.load(f)
class ExportOBJ(bpy.types.Operator):
"""Exports the IFC File to OBJ"""
bl_idname = "export_scene.radiance"
bl_label = "Export"
bl_description = "Export the IFC to OBJ"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.should_load_from_memory and not props.ifc_file:
cls.poll_message_set("Select an IFC file or use 'load from memory' if it's loaded in Bonsai.")
return False
return True
def execute(self, context):
# Get the output directory
props = tool.Blender.get_radiance_exporter_props()
should_load_from_memory = props.should_load_from_memory
output_dir = props.output_dir
props.is_exporting = True
# Conversion from IFC to OBJ
# Settings for obj
settings = ifcopenshell.geom.settings()
serializer_settings = ifcopenshell.geom.serializer_settings()
settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.SURFACES_AND_SOLIDS)
settings.set("apply-default-materials", True)
serializer_settings.set("use-element-guids", True)
settings.set("use-world-coords", True)
ifc_file: ifcopenshell.file
if should_load_from_memory:
ifc_file = tool.Ifc.get()
else:
ifc_file_path = props.ifc_file
ifc_file = ifcopenshell.open(ifc_file_path)
obj_file_path = os.path.join(output_dir, "model.obj")
mtl_file_path = os.path.join(output_dir, "model.mtl")
serialiser = ifcopenshell.geom.serializers.obj(obj_file_path, mtl_file_path, settings, serializer_settings)
serialiser.setFile(ifc_file)
serialiser.setUnitNameAndMagnitude("METER", 1.0)
serialiser.writeHeader()
if ifc_file.schema in ("IFC2X3", "IFC4"):
elements = ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcProxy")
else:
elements = ifc_file.by_type("IfcElement")
elements += ifc_file.by_type("IfcSite")
elements = [e for e in elements if not e.is_a("IfcFeatureElement") or e.is_a("IfcSurfaceFeature")]
iterator = ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements)
if iterator.initialize():
while True:
shape = iterator.get()
assert isinstance(shape, W.TriangulationElement)
materials = shape.geometry.materials
for material in materials:
ifc_materials.append(material.name)
serialiser.write(shape)
if not iterator.next():
break
serialiser.finalize()
props.is_exporting = False
self.report({"INFO"}, "Exported OBJ file to: {}".format(obj_file_path))
return {"FINISHED"}
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
def execute(self, context):
print("Starting Radiance rendering process...")
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Quality: {quality}, Detail: {detail}, Variability: {variability}")
print(f"Output directory: {output_dir}")
if use_hdr:
hdr_image = "noon_grass_2k.hdr"
hdr_mask = "noon_grass_2k_mask.hdr"
sky_map_cal = "skymap.cal"
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_image)
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", hdr_mask)
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", sky_map_cal)
obj_file_path = os.path.join(output_dir, "model.obj")
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
assert sun_pos_props
sky_file_path = os.path.join(output_dir, "sky.rad")
# latitude = sun_props.latitude
# longitude = sun_props.longitude
# month = sun_props.month
# day = sun_props.day
# hour = sun_props.hour
# minute = sun_props.minute
# print("Sun Properties:")
# print("Latitude: ", latitude)
# print("Longitude: ", longitude)
# print("Timezone: ", timezone)
# print("Month: ", month)
# print("Day: ", day)
# print("Hour: ", hour)
# print("Minute: ", minute)
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
# Get camera position and direction
camera_position, camera_direction = self.get_camera_data(camera)
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
# sun_position = tool.Blender.get_addon("sun_position")
# azimuth, elevation = sun_position.sun_calc.get_sun_coordinates(
# sun_pos_props.time,
# sun_pos_props.latitude,
# sun_pos_props.longitude,
# -sun_pos_props.UTC_zone,
# sun_pos_props.month,
# sun_pos_props.day,
# sun_pos_props.year,
# )
dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute)
sky_description = pr.gensky(
dt=dt,
# azimuth=64.1,
# altitude=-26.6,
latitude=sun_props.latitude,
longitude=sun_props.longitude,
year=sun_pos_props.year,
timezone=-int(sun_props.UTC_zone),
# sunny_with_sun=False,
# sunny_without_sun=False,
# cloudy=False,
# ground_reflectance=0.2,
# turbidity=3.0,
)
sky_description_str = sky_description.decode("utf-8")
# Write all this to file
# skyfunc glow sky_glow
# 0
# 0
# 4 .9 .9 1.15 0
# sky_glow source sky
# 0
# 0
# 4 0 0 1 180
# skyfunc glow ground_glow
# 0
# 0
# 4 1.4 .9 .6 0
# ground_glow source ground
# 0
# 0
# 4 0 0 -1 180
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" 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
+ '''" "'''
+ sky_map_cal_path
+ """" 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
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
# f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
# f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
# f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
# f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
props = tool.Blender.get_radiance_exporter_props()
data = props.get_mappings_dict()
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
# Write default materials
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
# "void plastic blue_plastic\n0\n0\n5 0.1 0.2 0.8 0.05 0.1\n",
# "void plastic red_plastic\n0\n0\n5 0.8 0.1 0.2 0.05 0.1\n",
# "void metal silver_metal\n0\n0\n5 0.8 0.8 0.8 0.9 0.1\n",
# "void glass clear_glass\n0\n0\n3 0.96 0.96 0.96\n",
# "void light white_light\n0\n0\n3 1.0 1.0 1.0\n",
# "void trans olive_trans\n0\n0\n7 0.6 0.7 0.4 0.05 0.05 0.7 0.2\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2]) # Add material name to written set
for style_id in all_materials:
material = next((m for m in props.materials if m.style_id == style_id), None)
if material and material.is_mapped:
category, subcategory = material.category, material.subcategory
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
# If the material is not mapped, alias it to white
file.write(f"inherit alias {style_id} white\n")
self.report({"INFO"}, f"Exported Materials Rad file to: {materials_file}")
# Run obj2mesh
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
# subprocess.run(["obj2mesh", "-a", materials_file, obj_file_path, rtm_file_path])
self.report({"INFO"}, "obj2mesh output: {}".format(mesh_file_path))
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file))
print("Setting up Radiance scene...")
scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
scene.add_material(material_path)
scene.add_surface(scene_path)
scene.add_source(sky_file_path)
print("Setting up view...")
assert isinstance(camera.data, bpy.types.Camera)
if camera.data.type == "PERSP":
# Perspective camera
camera_fov = camera.data.angle
# Calculate vertical FOV based on the desired aspect ratio
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.View(
vtype="v", # Perspective view
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=math.degrees(camera_fov),
vert=math.degrees(vertical_fov),
)
else: # 'ORTHO'
# Orthographic camera
# Calculate the view size based on the camera's orthographic scale
ortho_scale = camera.data.ortho_scale
view_width = ortho_scale
view_height = ortho_scale / aspect_ratio
aview = pr.View(
vtype="l", # Parallel projection (orthographic)
position=camera_position,
direction=camera_direction,
vup=(0, 0, 1), # Assuming Z is up
horiz=view_width,
vert=view_height,
)
scene.add_view(aview)
print("Starting render...")
start_time = time.time()
image = pr.render(
scene,
ambbounce=1,
resolution=(resolution_x, resolution_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
end_time = time.time()
print(f"Render completed in {end_time - start_time:.2f} seconds")
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.{output_file_format.lower()}")
print(f"Saving HDR output to: {output_hdr_path}")
if output_file_format == "HDR":
with open(output_hdr_path, "wb") as wtr:
wtr.write(image)
else:
pass
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, "Radiance rendering completed. Output: {}".format(tiff_path))
return {"FINISHED"}
def get_active_camera(self, context):
props = tool.Blender.get_radiance_exporter_props()
if props.use_active_camera:
return context.scene.camera
else:
return props.selected_camera
def get_camera_data(self, camera):
# Get camera position
position = camera.matrix_world.to_translation()
# Get camera direction
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
return (position.x, position.y, position.z), (direction.x, direction.y, direction.z)
def getResolution(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x = props.radiance_resolution_x
resolution_y = props.radiance_resolution_y
return resolution_x, resolution_y
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
class RefreshIFCMaterials(bpy.types.Operator):
bl_idname = "bim.refresh_ifc_materials"
bl_label = "Refresh IFC Materials"
bl_description = "Refresh the list of IFC materials for mapping"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
ifc_file: ifcopenshell.file
ifc_file = tool.Ifc.get() if props.should_load_from_memory else ifcopenshell.open(props.ifc_file)
props.materials.clear()
for style in ifc_file.by_type("IfcSurfaceStyle"):
for render_item in style.Styles:
if render_item.is_a("IfcSurfaceStyleRendering"):
style_id = f"IfcSurfaceStyleRendering-{render_item.id()}"
style_name = style.Name or f"Unnamed Style {render_item.id()}"
# Extract color and transparency
color = (1.0, 1.0, 1.0) # Default white
transparency = 0.0 # Default opaque
if render_item.SurfaceColour:
color = (
render_item.SurfaceColour.Red,
render_item.SurfaceColour.Green,
render_item.SurfaceColour.Blue,
)
if hasattr(render_item, "Transparency") and render_item.Transparency is not None:
transparency = render_item.Transparency
# Add material with color
material = props.add_material_mapping(style_id, style_name)
material.color = color
# If transparency is high, consider it as glass
if transparency > 0.5:
material.category = "Glass"
material.subcategory = "Clear Glass"
material.is_mapped = True
props.active_material_index = 0 if props.materials else -1
self.report({"INFO"}, f"Refreshed {len(props.materials)} IFC materials")
return {"FINISHED"}
class UnmapMaterial(bpy.types.Operator):
bl_idname = "bim.unmap_material"
bl_label = "Unmap Material"
bl_options = {"REGISTER", "UNDO"}
material_index: bpy.props.IntProperty()
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
material = props.materials[self.material_index]
props.unmap_material(material.name)
return {"FINISHED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper):
bl_idname = "radiance.export_material_mappings"
bl_label = "Export Material Mappings"
bl_description = "Export material mappings to a JSON file"
filename_ext = ".json"
filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"})
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
mappings = {}
for material in props.materials:
if material.is_mapped:
mappings[material.style_id] = {
"name": material.name,
"category": material.category,
"subcategory": material.subcategory,
}
with open(self.filepath, "w") as f:
json.dump(mappings, f, indent=4)
self.report({"INFO"}, f"Material mappings exported to {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper):
bl_idname = "radiance.import_material_mappings"
bl_label = "Import Material Mappings"
bl_description = "Import material mappings from a JSON file"
filename_ext = ".json"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.import_mappings(self.filepath)
self.report({"INFO"}, f"Material mappings imported from {self.filepath}")
return {"FINISHED"}
class RADIANCE_OT_open_spectraldb(bpy.types.Operator):
bl_idname = "radiance.open_spectraldb"
bl_label = "Open SpectralDB"
bl_description = "Open the SpectralDB website for reference"
def execute(self, context):
webbrowser.open("https://spectraldb.com")
return {"FINISHED"}
@@ -1,741 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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 math
import os
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
from mathutils import Vector
import bonsai.tool as tool
from bonsai.bim.module.light.prop import spectraldb
from bonsai.bim.module.light.shared import ifc_materials, linked_model_exports
def _matrix_to_xform_args(matrix: list[list[float]]) -> str:
"""Decompose a 4x4 matrix into Radiance xform arguments.
Decomposes into Z/Y/X Euler rotations + translation.
The matrix is expected in row-major format (Blender convention).
xform applies transforms right-to-left, so we write: -rx X -ry Y -rz Z -t tx ty tz
"""
from mathutils import Matrix as MMatrix
m = MMatrix(matrix)
translation = m.to_translation()
euler = m.to_euler("XYZ")
parts = []
rx = math.degrees(euler.x)
ry = math.degrees(euler.y)
rz = math.degrees(euler.z)
if abs(rx) > 1e-6:
parts.append(f"-rx {rx:.6f}")
if abs(ry) > 1e-6:
parts.append(f"-ry {ry:.6f}")
if abs(rz) > 1e-6:
parts.append(f"-rz {rz:.6f}")
parts.append(f"-t {translation.x:.6f} {translation.y:.6f} {translation.z:.6f}")
return " ".join(parts)
def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs):
try:
output_bytes = pr.obj2mesh(inp, **kwargs)
with open(output_file, "wb") as f:
f.write(output_bytes)
return output_file
except Exception as e:
print(f"ERROR in obj2mesh conversion:")
print(f" Input file: {inp}")
print(f" Output file: {output_file}")
print(f" Additional args: {kwargs}")
print(f" Error: {str(e)}")
raise
def convert_ies_to_radiance(
ies_file_path: str,
output_dir: str,
lamp_type: str = "",
lamp_color: tuple[float, float, float] = (1.0, 1.0, 1.0),
multiply_factor: float = 1.0,
radius: float = 0.0,
) -> tuple[str, str]:
"""Convert IES file to Radiance format using pyradiance.
Args:
ies_file_path: Path to the .ies file
output_dir: Directory where .rad and .dat files will be saved
lamp_type: Type of lamp (e.g., 'LED', 'metal halide')
lamp_color: RGB color tuple (0.0-1.0 each)
multiply_factor: Brightness multiplier (0.1-10.0)
radius: Illum sphere radius (0 = use IES geometry)
Returns:
Tuple of (rad_file_path, dat_file_path)
"""
try:
ies_path = Path(bpy.path.abspath(ies_file_path))
base_name = ies_path.stem
output_path = os.path.join(output_dir, base_name)
kwargs = {"outname": output_path}
if lamp_type:
kwargs["lamp_type"] = lamp_type
if lamp_color != (1.0, 1.0, 1.0):
kwargs["lamp_color"] = lamp_color
if multiply_factor != 1.0:
kwargs["multiply_factor"] = multiply_factor
if radius > 0.0:
kwargs["radius"] = radius
pr.ies2rad(ies_path, **kwargs)
rad_file = os.path.join(output_dir, f"{base_name}.rad")
dat_file = os.path.join(output_dir, f"{base_name}.dat")
return rad_file, dat_file
except Exception as e:
raise RuntimeError(f"Failed to convert IES file '{ies_file_path}': {str(e)}")
class PrepareRadianceScene(bpy.types.Operator):
"""Prepares the Radiance scene (runs heavy work in background thread)"""
bl_idname = "scene.prepare_radiance"
bl_label = "Prepare Radiance Scene"
bl_description = "Prepares the Radiance scene by creating necessary files and setting up the view"
_timer = None
_thread: Union[threading.Thread, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_preparing:
cls.poll_message_set("Scene preparation is already in progress.")
return False
return True
def get_camera_data(self, camera):
position = camera.matrix_world.to_translation()
direction = camera.matrix_world.to_quaternion() @ Vector((0, 0, -1))
direction.normalize()
up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0))
up.normalize()
if abs(direction.dot(up)) > 0.99:
if abs(direction.dot(Vector((0, 1, 0)))) > 0.99:
reference = Vector((1, 0, 0))
else:
reference = Vector((0, 1, 0))
right = direction.cross(reference)
if right.length < 0.01:
reference = Vector((0, 0, 1))
right = direction.cross(reference)
right.normalize()
up = right.cross(direction)
up.normalize()
if up.length < 0.01:
up = Vector((0, 0, 1))
else:
up.normalize()
return (
(position.x, position.y, position.z),
(direction.x, direction.y, direction.z),
(up.x, up.y, up.z),
)
def execute(self, context):
print("Starting Radiance scene preparation...")
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
obj_file_path = os.path.join(output_dir, "model.obj")
if not os.path.exists(obj_file_path):
error_msg = "OBJ file not found. Please run 'Export Geometry for Simulation' first."
self.report({"ERROR"}, error_msg)
print(f"ERROR: {error_msg}")
print(f" Expected file: {obj_file_path}")
return {"CANCELLED"}
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
assert context.scene
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
aspect_ratio = resolution_x / resolution_y
use_hdr = props.use_hdr
choose_hdr_image = props.choose_hdr_image
print(f"Resolution: {resolution_x}x{resolution_y}")
print(f"Output directory: {output_dir}")
print(f"Found OBJ file: {obj_file_path} ({os.path.getsize(obj_file_path)} bytes)")
hdr_image_path = ""
hdr_mask_path = ""
sky_map_cal_path = ""
if use_hdr:
hdr_image_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k.hdr")
hdr_mask_path = os.path.join(os.path.dirname(__file__), "HDRs", "noon_grass_2k_mask.hdr")
sky_map_cal_path = os.path.join(os.path.dirname(__file__), "HDRs", "skymap.cal")
sky_file_path = os.path.join(output_dir, "sky.rad")
print("Setting up camera...")
if props.use_active_camera:
camera = context.scene.camera
else:
camera = props.selected_camera
if camera is None:
self.report({"ERROR"}, "No active camera found in the scene. Please add a camera and set it as active.")
return {"CANCELLED"}
camera_position, camera_direction, camera_up = self.get_camera_data(camera)
camera_type = camera.data.type
camera_fov = camera.data.angle if camera_type == "PERSP" else 0.0
camera_ortho_scale = camera.data.ortho_scale if camera_type == "ORTHO" else 0.0
print(f"Camera position: {camera_position}")
print(f"Camera direction: {camera_direction}")
print(f"Camera up: {camera_up}")
# Collect sky generation data (must be on main thread)
sky_data = None
if props.use_sun:
sun_props = tool.Blender.get_solar_props()
sun_pos_props = tool.Blender.get_sun_props()
if not sun_pos_props:
self.report(
{"ERROR"}, "Sun position addon not available. Enable 'Sun Position' addon or disable 'Use Sun'."
)
return {"CANCELLED"}
sky_data = {
"year": sun_props.year,
"month": sun_props.month,
"day": sun_props.day,
"hour": sun_props.hour,
"minute": sun_props.minute,
"latitude": sun_props.latitude,
"longitude": sun_props.longitude,
"UTC_zone": sun_props.UTC_zone,
"sun_year": sun_pos_props.year,
"sky_condition": props.sky_condition,
"ground_reflectance": props.ground_reflectance,
"turbidity": props.turbidity,
}
# Collect IES light data (must be on main thread)
ies_light_data = []
for idx, ies_light in enumerate(props.ies_lights):
if not ies_light.is_enabled or not ies_light.ies_file_path:
ies_light_data.append(None)
continue
target_empties = ies_light.get_target_empties()
if not target_empties:
ies_light_data.append(None)
continue
positions = []
for obj in target_empties:
try:
positions.append((obj.location.x, obj.location.y, obj.location.z))
except ReferenceError:
continue
if not positions:
ies_light_data.append(None)
continue
ies_light_data.append(
{
"ies_file_path": ies_light.ies_file_path,
"lamp_type": ies_light.lamp_type,
"lamp_color": ies_light.lamp_color,
"multiply_factor": ies_light.multiply_factor,
"radius": ies_light.radius,
"rotation_z": ies_light.rotation_z,
"positions": positions,
"is_enabled": ies_light.is_enabled,
}
)
# Collect material mapping data (must be on main thread)
material_mappings = []
for m in props.materials:
material_mappings.append(
{
"style_id": m.style_id,
"is_mapped": m.is_mapped,
"category": m.category,
"subcategory": m.subcategory,
}
)
linked_exports_snapshot = list(linked_model_exports)
# All Blender data collected — now launch background thread
props.is_preparing = True
self._start_time = time.time()
self._error = None
import bonsai.bim.module.light.shared as shared
shared.scene = None
self._thread = threading.Thread(
target=self._prepare_worker,
args=(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Scene preparation started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _prepare_worker(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""Runs in a background thread — no Blender API calls allowed here."""
try:
self._do_prepare(
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
)
except Exception as e:
self._error = str(e)
import traceback
traceback.print_exc()
def _do_prepare(
self,
output_dir,
obj_file_path,
sky_file_path,
use_hdr,
choose_hdr_image,
hdr_image_path,
hdr_mask_path,
sky_map_cal_path,
sky_data,
ies_light_data,
material_mappings,
linked_exports_snapshot,
camera_position,
camera_direction,
camera_up,
camera_type,
camera_fov,
camera_ortho_scale,
aspect_ratio,
):
"""The actual preparation logic (no Blender API)."""
import bonsai.bim.module.light.shared as shared
# --- Generate sky file ---
if sky_data is not None:
dt = datetime(sky_data["year"], sky_data["month"], sky_data["day"], sky_data["hour"], sky_data["minute"])
print(f"Sun position data for Radiance gensky:")
print(f" DateTime: {dt}")
print(f" Latitude: {sky_data['latitude']}°")
print(f" Longitude: {sky_data['longitude']}°")
sky_condition = sky_data["sky_condition"]
longitude_for_gensky = -sky_data["longitude"]
timezone_for_gensky = -int(sky_data["UTC_zone"]) * 15
sky_description = pr.gensky(
dt=dt,
latitude=sky_data["latitude"],
longitude=longitude_for_gensky,
year=sky_data["sun_year"],
timezone=timezone_for_gensky,
sunny_with_sun=sky_condition == "SUNNY_WITH_SUN",
sunny_without_sun=sky_condition == "SUNNY_WITHOUT_SUN",
cloudy=sky_condition == "CLOUDY",
ground_reflectance=sky_data["ground_reflectance"],
turbidity=sky_data["turbidity"],
)
sky_description_str = sky_description.decode("utf-8")
if use_hdr and choose_hdr_image == "Noon":
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write(
'''void colorpict env_map
7 red green blue "'''
+ hdr_image_path
+ '''" "'''
+ sky_map_cal_path
+ '''" 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
+ '''" "'''
+ sky_map_cal_path
+ """" 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
4 0 0 -1 180"""
)
elif not use_hdr:
with open(sky_file_path, "w") as f:
f.write(sky_description_str)
f.write("\n")
f.write("skyfunc glow sky_glow\n0\n0\n4 .9 .9 1.15 0\n")
f.write("sky_glow source sky\n0\n0\n4 0 0 1 180\n")
f.write("skyfunc glow ground_glow\n0\n0\n4 1.4 .9 .6 0\n")
f.write("ground_glow source ground\n0\n0\n4 0 0 -1 180\n")
else:
print("Skipping sky generation (use_sun is False)...")
# --- Write materials.rad ---
materials_file = os.path.join(output_dir, "materials.rad")
written_materials = set()
all_materials = set(ifc_materials)
with open(materials_file, "w") as file:
default_materials = [
"void plastic white\n0\n0\n5 0.8 0.8 0.8 0 0\n",
]
for material in default_materials:
file.write(material)
written_materials.add(material.split()[2])
for style_id in all_materials:
mat_data = next((m for m in material_mappings if m["style_id"] == style_id), None)
if mat_data and mat_data["is_mapped"]:
category, subcategory = mat_data["category"], mat_data["subcategory"]
if category in spectraldb and subcategory in spectraldb[category]:
material_def = spectraldb[category][subcategory]
material_name = material_def.split()[2]
if material_name not in written_materials:
file.write(material_def + "\n")
written_materials.add(material_name)
file.write(f"inherit alias {style_id} {material_name}\n")
else:
file.write(f"inherit alias {style_id} white\n")
else:
file.write(f"inherit alias {style_id} white\n")
print(f"Exported Materials Rad file to: {materials_file}")
# --- Convert IES light files ---
print("Processing IES light files...")
converted_ies_lights = {}
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
try:
rad_file, dat_file = convert_ies_to_radiance(
ies_data["ies_file_path"],
output_dir,
lamp_type=ies_data["lamp_type"],
lamp_color=ies_data["lamp_color"],
multiply_factor=ies_data["multiply_factor"],
radius=ies_data["radius"],
)
converted_ies_lights[idx] = (rad_file, dat_file)
print(f" Converted IES light {idx}: {Path(ies_data['ies_file_path']).name}")
except Exception as e:
print(f" ERROR converting IES light {idx}: {str(e)}")
# --- Convert OBJ to RTM ---
print(f"OBJ file size: {os.path.getsize(obj_file_path)} bytes")
print(f"Materials file size: {os.path.getsize(materials_file)} bytes")
print(f"Converting OBJ to RTM format...")
rtm_file_path = os.path.join(output_dir, "model.rtm")
mesh_file_path = save_obj2mesh_output(obj_file_path, rtm_file_path, matfiles=[materials_file])
print(f"obj2mesh output: {mesh_file_path}")
# Convert linked model OBJs to RTM
linked_rtm_files = []
for link_idx, (link_obj_path, link_mtl_path, link_matrix) in enumerate(linked_exports_snapshot):
if not os.path.exists(link_obj_path):
print(f"Linked model OBJ not found: {link_obj_path}")
continue
link_rtm_path = os.path.join(output_dir, f"linked_{link_idx}.rtm")
try:
save_obj2mesh_output(link_obj_path, link_rtm_path, matfiles=[materials_file])
linked_rtm_files.append((link_rtm_path, link_matrix))
print(f"Converted linked model {link_idx} to RTM")
except Exception as e:
print(f"Failed to convert linked model {link_idx} to RTM: {e}")
# --- Write scene.rad ---
scene_file = os.path.join(output_dir, "scene.rad")
with open(scene_file, "w") as file:
file.write('void mesh model\n1 "' + rtm_file_path + '"\n0\n0\n')
file.write("\n")
if linked_rtm_files:
file.write("\n# Linked Models\n")
for link_idx, (link_rtm_path, link_matrix) in enumerate(linked_rtm_files):
is_identity = all(
abs(link_matrix[i][j] - (1.0 if i == j else 0.0)) < 1e-6 for i in range(4) for j in range(4)
)
if is_identity:
file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n\n')
print(f"Added linked model {link_idx} to scene (identity, inline mesh)")
else:
link_rad_path = os.path.join(output_dir, f"linked_{link_idx}.rad")
with open(link_rad_path, "w") as link_file:
link_file.write(f'void mesh linked_{link_idx}\n1 "{link_rtm_path}"\n0\n0\n')
xform_args = _matrix_to_xform_args(link_matrix)
file.write(f'!xform {xform_args} "{link_rad_path}"\n')
print(f"Added linked model {link_idx} to scene with xform: {xform_args}")
# IES light fixtures
if ies_light_data:
file.write("\n# IES Light Fixtures\n")
for idx, ies_data in enumerate(ies_light_data):
if ies_data is None:
continue
z_rot = math.degrees(ies_data["rotation_z"])
for pos in ies_data["positions"]:
if idx in converted_ies_lights:
rad_path = converted_ies_lights[idx][0]
rad_filename = Path(rad_path).name
file.write(f'!xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
else:
rad_base = Path(ies_data["ies_file_path"]).stem
rad_filename = f"{rad_base}.rad"
file.write(f'# !xform -rz {z_rot} -t {pos[0]} {pos[1]} {pos[2]} "{rad_filename}"\n')
print(f"Exported Scene file to: {scene_file}")
# --- Validate light sources ---
has_sky = sky_data is not None
has_ies_lights = len(converted_ies_lights) > 0
if not has_sky and not has_ies_lights:
raise RuntimeError("No light sources available. Please enable 'Use Sun' or add and map IES light fixtures.")
# --- Build pr.Scene ---
print("Setting up Radiance scene...")
new_scene = pr.Scene("ascene")
material_path = os.path.join(output_dir, "materials.rad")
scene_path = os.path.join(output_dir, "scene.rad")
new_scene.add_material(material_path)
new_scene.add_surface(scene_path)
if has_sky:
new_scene.add_source(sky_file_path)
print(f"Added sky light source")
if has_ies_lights:
print(f"Added {len(converted_ies_lights)} IES light source(s)")
print("Setting up view...")
if camera_type == "PERSP":
vertical_fov = 2 * math.atan(math.tan(camera_fov / 2) / aspect_ratio)
aview = pr.create_default_view()
aview.type = "v"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = math.degrees(camera_fov)
aview.vert = math.degrees(vertical_fov)
else:
view_width = camera_ortho_scale
view_height = camera_ortho_scale / aspect_ratio
aview = pr.create_default_view()
aview.type = "l"
aview.vp = camera_position
aview.vdir = camera_direction
aview.vu = camera_up
aview.horiz = view_width
aview.vert = view_height
new_scene.add_view(aview)
# Set the global scene reference (thread-safe assignment)
shared.scene = new_scene
print("Scene preparation complete.")
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
# Thread finished — clean up
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Scene preparation failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
self.report({"INFO"}, f"Radiance scene prepared successfully in {elapsed:.1f}s")
print(f"Scene preparation completed in {elapsed:.2f} seconds")
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_preparing = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Scene preparation cannot be cancelled mid-operation")
return {"RUNNING_MODAL"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
+29 -312
View File
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import calendar
import datetime
import json
import os
@@ -44,6 +43,7 @@ from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.decorator import SolarDecorator
sun_position = tool.Blender.get_addon("sun_position")
now = datetime.datetime.now()
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb: dict[str, dict[str, str]] = json.load(f)
@@ -72,8 +72,8 @@ def update_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -
def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
sun_props = tool.Blender.get_sun_props()
assert sun_props
sun_props.longitude = self.longitude
sun_props.latitude = self.latitude
sun_props.longitude = sun_props.longitude
sun_props.latitude = sun_props.latitude
self["coordinates"] = sun_props.coordinates
update_sun_path(self)
@@ -135,14 +135,6 @@ def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Con
context.scene.render.resolution_y = self.radiance_resolution_y
def update_day(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
"""Clamp the day to the valid range for the current month/year."""
max_day = calendar.monthrange(self.year, self.month)[1]
if self.day > max_day:
self["day"] = max_day
update_sun_path(self)
def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context, None] = None) -> None:
if not SolarData.is_loaded:
SolarData.load()
@@ -160,13 +152,9 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
sun_props.sun_distance = self.sun_path_size
sun_props.latitude = self.latitude
sun_props.longitude = self.longitude
# Clamp day to valid range for the current month/year
max_day = calendar.monthrange(self.year, self.month)[1]
day = min(self.day, max_day)
sun_props.year = self.year
sun_props.month = self.month
sun_props.day = day
sun_props.day = self.day
sun_props.time = self.hour + (self.minute / 60)
# Preserve IFC sign convention
sun_props.north_offset = self.true_north * -1
@@ -193,11 +181,8 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
)
sun_vector = sun_position.sun_calc.get_sun_vector(azimuth, elevation) * sun_props.sun_distance
props.sun_position = sun_vector
# Update Blender viewport light direction for shadow visualization
# This coordinate transformation converts from sun_position addon's coordinate system
# to Blender's display.light_direction coordinate system
# Note: This only affects viewport shading, not the Radiance rendering
# sun_vector.z = max(0, sun_vector.z)
# Light direction is a bit weird?
mat = Matrix(((-1.0, 0.0, 0.0, 0.0), (0.0, 0, 1.0, 0.0), (-0.0, -1.0, 0, 0.0), (0.0, 0.0, 0.0, 1.0))).inverted()
rotation_euler = Euler((elevation - pi / 2, 0, -azimuth))
rotation_quaternion = rotation_euler.to_quaternion()
@@ -208,9 +193,6 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
assert bpy.context.scene
assert bpy.context.scene.display
# Set viewport light direction based on sun position
# If sun is below horizon (z < 0), use default upward direction
# Otherwise, use calculated direction from sun position
if sun_vector.z < 0:
bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1))
else:
@@ -241,106 +223,6 @@ class RadianceMaterial(PropertyGroup):
color: tuple[float, float, float]
class IESLight(PropertyGroup):
"""Represents a mapping between an IES light file and scene Empty objects.
Supports two targeting modes:
- Object mode: target a single Empty object
- Collection mode: target all Empty objects in a collection
"""
ies_file_path: StringProperty(
name="IES File Path",
description="Path to the IES luminaire data file",
subtype="FILE_PATH",
default="",
)
use_collection: BoolProperty(
name="Use Collection",
description="Apply this light to all Empty objects in a collection instead of a single object",
default=False,
)
target_object: PointerProperty(
type=bpy.types.Object,
name="Target Object",
description="Empty object where the light fixture will be placed",
poll=lambda self, obj: obj.type == "EMPTY",
)
target_collection: PointerProperty(
type=bpy.types.Collection,
name="Target Collection",
description="Collection of Empty objects where the light fixture will be placed",
)
rotation_z: FloatProperty(
name="Rotation Z",
description="Rotation around Z-axis in degrees (-180 to 180)",
min=-180.0,
max=180.0,
default=0.0,
subtype="ANGLE",
)
is_enabled: BoolProperty(
name="Enabled",
description="Include this light in the Radiance export",
default=True,
)
# Additional lamp properties for customization
lamp_type: StringProperty(
name="Lamp Type",
description="Type of lamp (e.g., 'LED', 'metal halide', 'fluorescent')",
default="",
)
lamp_color: FloatVectorProperty(
name="Lamp Color",
description="Lamp color (RGB) for custom color adjustments",
subtype="COLOR",
default=(1.0, 1.0, 1.0),
min=0.0,
max=1.0,
size=3,
)
multiply_factor: FloatProperty(
name="Brightness Factor",
description="Multiply all output quantities by this factor (0.1 to 10.0)",
min=0.1,
max=10.0,
default=1.0,
)
radius: FloatProperty(
name="Illum Sphere Radius",
description="Radius of illum sphere (ignores geometry from IES file). 0 = use IES geometry",
min=0.0,
max=10.0,
default=0.0,
)
def get_target_empties(self) -> list[bpy.types.Object]:
"""Return all Empty objects this light targets."""
if self.use_collection and self.target_collection is not None:
return [obj for obj in self.target_collection.all_objects if obj.type == "EMPTY" and not obj.hide_get()]
elif not self.use_collection and self.target_object is not None:
try:
_ = self.target_object.name
if not self.target_object.hide_get():
return [self.target_object]
except ReferenceError:
pass
return []
if TYPE_CHECKING:
ies_file_path: str
use_collection: bool
target_object: Union[bpy.types.Object, None]
target_collection: Union[bpy.types.Collection, None]
rotation_z: float
is_enabled: bool
lamp_type: str
lamp_color: tuple[float, float, float]
multiply_factor: float
radius: float
class RadianceExporterProperties(PropertyGroup):
def update_output_dir(self, context) -> None:
@@ -351,9 +233,6 @@ class RadianceExporterProperties(PropertyGroup):
if self.ifc_file:
self.ifc_file = bpy.path.abspath(self.ifc_file)
def get_categories(self, context):
return sorted([(k, k, "") for k in spectraldb.keys()])
def add_material_mapping(self, style_id: str, style_name: str) -> RadianceMaterial:
item = self.materials.add()
item.name = style_name
@@ -368,10 +247,7 @@ class RadianceExporterProperties(PropertyGroup):
mappings = json.load(f)
for style_id, mapping in mappings.items():
# Try matching by style_id first, fall back to name
material = self.get_material_mapping_by_id(style_id)
if material is None:
material = self.get_material_mapping(mapping["name"])
material = self.get_material_mapping(mapping["name"])
if material:
material.style_id = style_id
material.category = mapping["category"]
@@ -383,14 +259,11 @@ class RadianceExporterProperties(PropertyGroup):
new_material.subcategory = mapping["subcategory"]
new_material.is_mapped = True
def get_material_mapping_by_id(self, style_id: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.style_id == style_id), None)
def get_material_mapping(self, style_name: str) -> Union[RadianceMaterial, None]:
return next((item for item in self.materials if item.name == style_name), None)
def set_material_mapping(self, style_id: str, style_name: str, category: str, subcategory: str) -> None:
item = self.get_material_mapping_by_id(style_id) or self.get_material_mapping(style_name)
item = self.get_material_mapping(style_name)
if item:
item.category = category
item.subcategory = subcategory
@@ -406,12 +279,8 @@ class RadianceExporterProperties(PropertyGroup):
if item.category and item.subcategory
}
def unmap_material(self, style_name: str, style_id: str = "") -> None:
item = None
if style_id:
item = self.get_material_mapping_by_id(style_id)
if item is None:
item = self.get_material_mapping(style_name)
def unmap_material(self, style_name: str) -> None:
item = self.get_material_mapping(style_name)
if item:
item.category = ""
item.subcategory = ""
@@ -421,14 +290,6 @@ class RadianceExporterProperties(PropertyGroup):
name="Is Exporting", description="Whether the OBJ export is in progress", default=False
)
is_preparing: bpy.props.BoolProperty(
name="Is Preparing", description="Whether scene preparation is in progress", default=False
)
is_rendering: bpy.props.BoolProperty(
name="Is Rendering", description="Whether a Radiance render is in progress", default=False
)
categories = [
("Wall", "Wall", ""),
("Floor", "Floor", ""),
@@ -455,13 +316,16 @@ class RadianceExporterProperties(PropertyGroup):
print(f"Material '{active_material.name}' mapped to {self.category} - {self.subcategory}")
category: bpy.props.EnumProperty(
items=get_categories, name="Category", description="Material category", update=update_material_mapping
items=categories, name="Category", description="Material category", update=update_material_mapping
)
def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS:
global SUBCATEGORIES_ENUM_ITEMS
if self.category in spectraldb:
return sorted([(k, k, "") for k in spectraldb[self.category].keys()])
return []
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
SUBCATEGORIES_ENUM_ITEMS = []
return SUBCATEGORIES_ENUM_ITEMS
subcategory: bpy.props.EnumProperty(
items=get_subcategories, name="Subcategory", description="Material subcategory", update=update_material_mapping
@@ -470,9 +334,12 @@ class RadianceExporterProperties(PropertyGroup):
materials: CollectionProperty(type=RadianceMaterial)
active_material_index: IntProperty()
# material_mappings: bpy.props.CollectionProperty(type=bpy.types.PropertyGroup, name="Material Mappings")
# material_mappings: CollectionProperty(type=MaterialMapping)
should_load_from_memory: BoolProperty(
name="Load from Memory",
default=True,
default=False,
)
radiance_resolution_x: IntProperty(
@@ -481,13 +348,6 @@ class RadianceExporterProperties(PropertyGroup):
radiance_resolution_y: IntProperty(
name="Y", description="Vertical resolution of the output image", default=1080, min=1, update=update_resolution
)
radiance_bin_dir: StringProperty(
name="Radiance Bin",
description="Path to the Radiance bin folder (containing falsecolor, pcomb, etc.)",
default="",
subtype="DIR_PATH",
)
output_dir: StringProperty(
name="Output Directory",
description="Directory to output Radiance files",
@@ -503,15 +363,6 @@ class RadianceExporterProperties(PropertyGroup):
update=lambda self, context: self.update_ifc_file(context),
)
ambient_bounces: IntProperty(
name="Ambient Bounces",
description="Number of indirect light bounces. Higher = more light fills dark areas but slower. "
"1 is minimal, 2-3 recommended for IES-only scenes, 4+ for complex interiors",
min=0,
max=8,
default=2,
)
radiance_quality: EnumProperty(
name="Quality",
description="Radiance rendering quality",
@@ -542,15 +393,14 @@ class RadianceExporterProperties(PropertyGroup):
name="Output File Format",
description="Format of the output image file",
items=[
("HDR", "HDR", "High Dynamic Range (HDR) file only"),
("HDR_TIFF", "HDR + Tiff", "High Dynamic Range (HDR) and Tiff files"),
("HDR", "HDR + Tiff", "High Dynamic Range"),
],
default="HDR_TIFF",
default="HDR",
)
use_hdr: BoolProperty(
name="HDR Environment Map",
description="Use an HDR image as the sky dome for realistic environment lighting and reflections",
name="Use HDR",
description="Use HDR image format",
default=True,
)
@@ -563,40 +413,6 @@ class RadianceExporterProperties(PropertyGroup):
default="Noon",
)
use_sun: BoolProperty(
name="Use Sun",
description="Use sun position data to generate sky. If disabled, generates a default sky without sun",
default=True,
)
# Sky generation parameters
sky_condition: EnumProperty(
name="Sky Condition",
description="Type of sky condition to generate",
items=[
("SUNNY_WITH_SUN", "Sunny with Sun", "Clear sky with direct sun"),
("SUNNY_WITHOUT_SUN", "Sunny without Sun", "Clear sky without direct sun"),
("CLOUDY", "Cloudy", "Overcast sky condition"),
],
default="SUNNY_WITH_SUN",
)
ground_reflectance: FloatProperty(
name="Ground Reflectance",
description="Ground reflectance value (0.0 to 1.0)",
min=0.0,
max=1.0,
default=0.2,
)
turbidity: FloatProperty(
name="Turbidity",
description="Atmospheric turbidity (1.0 to 10.0). Lower values = clearer sky",
min=1.0,
max=10.0,
default=3.0,
)
use_active_camera: BoolProperty(
name="Use Active Camera", description="Use the active camera in the scene", default=True
)
@@ -608,90 +424,8 @@ class RadianceExporterProperties(PropertyGroup):
poll=lambda self, object: object.type == "CAMERA",
)
ies_lights: CollectionProperty(
type=IESLight,
name="IES Lights",
description="Collection of IES light fixtures mapped to scene objects",
)
active_ies_light_index: IntProperty(
name="Active IES Light Index",
description="Index of the active IES light in the collection",
default=-1,
)
# False color analysis properties
use_false_color: BoolProperty(
name="Generate False Color Image",
description="Generate a false color HDR image for illuminance analysis",
default=False,
)
false_color_label: EnumProperty(
name="Legend Label Unit",
description="Unit for the false color legend",
items=[
("fc", "Foot-Candles", "US lighting standard unit"),
("lux", "Lux", "International lighting standard unit"),
("cd/m2", "Candela per m²", "Luminance unit"),
],
default="fc",
)
false_color_scale: FloatProperty(
name="Scale Factor",
description="Maximum scale value for the false color legend",
min=0.1,
max=100000.0,
default=3.0,
)
false_color_steps: IntProperty(
name="Legend Steps",
description="Number of divisions on the legend. Contour increment = Scale / Steps. "
"E.g. Scale=20, Steps=10 → contours at 2, 4, 6, 8...",
min=2,
max=50,
default=10,
)
false_color_contour_lines: BoolProperty(
name="Enable Contour Lines",
description="Add contour lines to the false color image",
default=True,
)
false_color_contour_mode: EnumProperty(
name="Contour Mode",
description="Contour line display mode",
items=[
(
"WITH_BG",
"Contour Lines with Background",
"Show contour lines overlaid on the colored false color background",
),
("WITHOUT_BG", "Contour Lines Only", "Show only contour lines without the false color background"),
],
default="WITH_BG",
)
false_color_multiplier: FloatProperty(
name="Multiplier",
description="Conversion multiplier (179.0 for lux, 16.6295 for foot-candles)",
min=0.1,
max=1000.0,
default=16.629505759940542,
)
false_color_output_name: StringProperty(
name="False Color Output Name",
description="Name of the false color output file (without extension)",
default="false_color",
)
if TYPE_CHECKING:
is_exporting: bool
is_preparing: bool
is_rendering: bool
category: str
subcategory: str
materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial]
@@ -699,10 +433,8 @@ class RadianceExporterProperties(PropertyGroup):
should_load_from_memory: bool
radiance_resolution_x: int
radiance_resolution_y: int
radiance_bin_dir: str
output_dir: str
ifc_file: str
ambient_bounces: int
radiance_quality: Literal["LOW", "MEDIUM", "HIGH"]
radiance_detail: Literal["LOW", "MEDIUM", "HIGH"]
radiance_variability: Literal["LOW", "MEDIUM", "HIGH"]
@@ -710,22 +442,8 @@ class RadianceExporterProperties(PropertyGroup):
output_file_format: Literal["HDR"]
use_hdr: bool
choose_hdr_image: Literal["Noon"]
use_sun: bool
sky_condition: Literal["SUNNY_WITH_SUN", "SUNNY_WITHOUT_SUN", "CLOUDY"]
ground_reflectance: float
turbidity: float
use_active_camera: bool
selected_camera: Union[bpy.types.Object, None]
ies_lights: bpy.types.bpy_prop_collection_idprop[IESLight]
active_ies_light_index: int
use_false_color: bool
false_color_label: Literal["fc", "lux", "cd/m2"]
false_color_scale: float
false_color_steps: int
false_color_contour_lines: bool
false_color_contour_mode: Literal["WITH_BG", "WITHOUT_BG"]
false_color_multiplier: float
false_color_output_name: str
class BIMSolarProperties(PropertyGroup):
@@ -752,12 +470,11 @@ class BIMSolarProperties(PropertyGroup):
)
timezone: StringProperty(name="Timezone", default="Etc/GMT")
true_north: FloatProperty(name="True North", min=-pi, max=pi, subtype="ANGLE", update=update_sun_path)
# Defaults are static; use the "Now" button (LightSetTimeToNow) to set current time.
year: IntProperty(name="Year", min=1, max=9999, default=2025, update=update_day)
month: IntProperty(name="Month", min=1, max=12, default=1, update=update_day)
day: IntProperty(name="Date", min=1, max=31, default=1, update=update_day)
hour: IntProperty(name="Hour", min=0, max=23, default=12, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=0, update=update_sun_path)
year: IntProperty(name="Year", min=1, max=9999, default=now.year, update=update_sun_path)
month: IntProperty(name="Month", min=1, max=12, default=now.month, update=update_sun_path)
day: IntProperty(name="Date", min=1, max=31, default=now.day, update=update_sun_path)
hour: IntProperty(name="Hour", min=0, max=23, default=now.hour, update=update_sun_path)
minute: IntProperty(name="Minute", min=0, max=59, default=now.minute, update=update_sun_path)
sun_position: FloatVectorProperty(name="Sun Position", subtype="XYZ", default=(0, 0, 0))
sun_path_origin: FloatVectorProperty(name="Sun Path Origin", subtype="XYZ", default=(0, 0, 0))
sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path)
@@ -1,328 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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 multiprocessing
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Union
import bpy
import pyradiance as pr
import bonsai.bim.module.light.shared as shared
import bonsai.tool as tool
# pyradiance's bundled Radiance binaries
_PYRAD_BIN = Path(pr.__file__).parent / "bin"
class RadianceRender(bpy.types.Operator):
"""Radiance Rendering (runs in background thread)"""
bl_idname = "render_scene.radiance"
bl_label = "Render"
bl_description = "Renders the scene using Radiance"
_timer = None
_thread: Union[threading.Thread, None] = None
_result_image: Union[bytes, None] = None
_error: Union[str, None] = None
_start_time: float = 0.0
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
if props.is_rendering:
cls.poll_message_set("A render is already in progress.")
return False
if shared.scene is None:
cls.poll_message_set("Radiance scene not prepared. Please run 'Prepare Scene' (Step 2) first.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
resolution_x, resolution_y = props.radiance_resolution_x, props.radiance_resolution_y
context.scene.render.resolution_x = resolution_x
context.scene.render.resolution_y = resolution_y
quality = props.radiance_quality.upper()
detail = props.radiance_detail.upper()
variability = props.radiance_variability.upper()
ambient_bounces = props.ambient_bounces
output_dir = props.output_dir
props.is_rendering = True
self._start_time = time.time()
self._result_image = None
self._error = None
self._thread = threading.Thread(
target=self._render_worker,
args=(shared.scene, output_dir, resolution_x, resolution_y, quality, detail, variability, ambient_bounces),
daemon=True,
)
self._thread.start()
wm = context.window_manager
self._timer = wm.event_timer_add(0.5, window=context.window)
wm.modal_handler_add(self)
self.report({"INFO"}, "Radiance render started in background...")
context.window.cursor_set("WAIT")
return {"RUNNING_MODAL"}
def _render_worker(self, render_scene, output_dir, res_x, res_y, quality, detail, variability, ambient_bounces):
"""Runs in a background thread — no Blender API calls allowed here."""
cwd_saved = os.getcwd()
try:
os.chdir(output_dir)
self._result_image = pr.render(
render_scene,
ambbounce=ambient_bounces,
resolution=(res_x, res_y),
quality=quality,
detail=detail,
variability=variability,
nproc=multiprocessing.cpu_count(),
)
except Exception as e:
self._error = str(e)
finally:
os.chdir(cwd_saved)
def modal(self, context, event):
if event.type == "TIMER":
if self._thread is not None and self._thread.is_alive():
return {"RUNNING_MODAL"}
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
if self._error:
self.report({"ERROR"}, f"Radiance render failed: {self._error}")
return {"CANCELLED"}
elapsed = time.time() - self._start_time
print(f"Render completed in {elapsed:.2f} seconds")
output_dir = props.output_dir
output_file_name = props.output_file_name
output_file_format = props.output_file_format
output_hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
print(f"Saving HDR output to: {output_hdr_path}")
with open(output_hdr_path, "wb") as wtr:
wtr.write(self._result_image)
if output_file_format == "HDR_TIFF":
print("Applying tone mapping...")
pcond_image = pr.pcond(hdr=output_hdr_path, human=True)
tiff_path = os.path.join(output_dir, f"{output_file_name}.tiff")
print(f"Saving TIFF output to: {tiff_path}")
pr.ra_tiff(inp=pcond_image, out=tiff_path, lzw=True)
print("Radiance rendering process completed successfully.")
self.report({"INFO"}, f"Radiance rendering completed. HDR Output: {output_hdr_path}")
if output_file_format == "HDR_TIFF":
self.report({"INFO"}, f"TIFF Output: {tiff_path}")
for area in context.screen.areas:
area.tag_redraw()
return {"FINISHED"}
elif event.type == "ESC":
self._cleanup_timer(context)
props = tool.Blender.get_radiance_exporter_props()
props.is_rendering = False
context.window.cursor_set("DEFAULT")
self.report({"WARNING"}, "Render cancelled by user. Background process may still be running.")
return {"CANCELLED"}
return {"PASS_THROUGH"}
def _cleanup_timer(self, context):
if self._timer is not None:
context.window_manager.event_timer_remove(self._timer)
self._timer = None
class FalseColorRadiance(bpy.types.Operator):
"""Generate false color HDR image for illuminance analysis"""
bl_idname = "render_scene.false_color_radiance"
bl_label = "Generate False Color Image"
bl_description = "Generate a false color HDR image for illuminance analysis"
@classmethod
def poll(cls, context):
props = tool.Blender.get_radiance_exporter_props()
if not props.output_dir:
cls.poll_message_set("Output directory is not set.")
return False
return True
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
output_dir = props.output_dir
output_file_name = props.output_file_name
hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr")
if not os.path.exists(hdr_path):
self.report(
{"ERROR"},
f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first.",
)
return {"CANCELLED"}
fc_scale = (
str(int(props.false_color_scale))
if props.false_color_scale == int(props.false_color_scale)
else str(props.false_color_scale)
)
# Multiplier converts Radiance raw values to display units
# fc (foot-candles) = 16.629..., lux & cd/m2 = 179.0
multiplier = 179.0 if props.false_color_label in ("lux", "cd/m2") else 16.629505759940542
print(
f"False color parameters: label={props.false_color_label}, scale={fc_scale}, "
f"steps={props.false_color_steps}, multiplier={multiplier}, "
f"contour={props.false_color_contour_lines}"
)
try:
fc_output_name = props.false_color_output_name
fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr")
# Find falsecolor binary from user-specified Radiance bin directory
radiance_bin_dir = os.path.normpath(props.radiance_bin_dir) if props.radiance_bin_dir else ""
if radiance_bin_dir:
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor.exe")
if not os.path.exists(falsecolor_bin):
falsecolor_bin = os.path.join(radiance_bin_dir, "falsecolor")
if not os.path.exists(falsecolor_bin):
self.report({"ERROR"}, f"falsecolor not found in: {radiance_bin_dir}")
return {"CANCELLED"}
else:
import shutil
falsecolor_bin = shutil.which("falsecolor") or shutil.which("falsecolor.exe")
if not falsecolor_bin:
self.report(
{"ERROR"}, "Set the Radiance Bin path in False Color settings, or add Radiance to system PATH."
)
return {"CANCELLED"}
radiance_bin_dir = os.path.dirname(falsecolor_bin)
radiance_lib = os.path.join(os.path.dirname(radiance_bin_dir), "lib")
print(f"Using falsecolor: {falsecolor_bin}")
print(f"Radiance bin: {radiance_bin_dir}, lib: {radiance_lib}")
cmd = [falsecolor_bin]
cmd.extend(["-m", str(multiplier)])
cmd.extend(["-s", fc_scale])
cmd.extend(["-n", str(props.false_color_steps)])
cmd.extend(["-l", props.false_color_label])
if props.false_color_contour_lines:
cmd.append("-cl")
if props.false_color_contour_mode == "WITH_BG":
cmd.extend(["-ip", hdr_path])
else:
cmd.extend(["-i", hdr_path])
else:
cmd.extend(["-ip", hdr_path])
# Setup environment so falsecolor can find pcomb, psign, pcompos etc.
env = os.environ.copy()
if os.path.exists(radiance_bin_dir):
env["PATH"] = radiance_bin_dir + os.pathsep + env.get("PATH", "")
if os.path.exists(radiance_lib):
env["RAYPATH"] = "." + os.pathsep + radiance_lib
print(f"Running: {' '.join(cmd)}")
# Write output to file via redirection to avoid Windows stdout binary corruption
cmd_str = subprocess.list2cmdline(cmd) + f' > "{fc_hdr_path}"'
result = subprocess.run(cmd_str, shell=True, stderr=subprocess.PIPE, env=env, cwd=output_dir)
if result.returncode != 0:
error_msg = result.stderr.decode() if result.stderr else "Unknown error"
self.report({"ERROR"}, f"falsecolor failed: {error_msg}")
return {"CANCELLED"}
fc_size = os.path.getsize(fc_hdr_path) if os.path.exists(fc_hdr_path) else 0
print(f"False color HDR generated: {fc_hdr_path} ({fc_size} bytes)")
self.report({"INFO"}, f"False color image generated: {fc_hdr_path}")
# Generate TIFF version
try:
pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True)
fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff")
pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True)
print(f"False color TIFF generated: {fc_tiff_path}")
self.report({"INFO"}, f"False color TIFF also generated: {fc_tiff_path}")
except Exception as e:
self.report({"WARNING"}, f"TIFF generation failed: {str(e)}")
return {"FINISHED"}
except subprocess.CalledProcessError as e:
# Clean up incomplete HDR file on failure
if os.path.exists(fc_hdr_path):
os.remove(fc_hdr_path)
error_msg = f"falsecolor failed: {e.stderr.decode() if e.stderr else str(e)}"
self.report({"ERROR"}, error_msg)
return {"CANCELLED"}
except FileNotFoundError:
self.report({"ERROR"}, "falsecolor not found. Please install Radiance and add it to PATH.")
return {"CANCELLED"}
except Exception as e:
self.report({"ERROR"}, f"Failed to generate false color image: {str(e)}")
import traceback
traceback.print_exc()
return {"CANCELLED"}
class RADIANCE_OT_select_camera(bpy.types.Operator):
bl_idname = "radiance.select_camera"
bl_label = "Select Camera"
bl_description = "Select a camera from the viewport"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
return context.object is not None and context.object.type == "CAMERA"
def execute(self, context):
props = tool.Blender.get_radiance_exporter_props()
props.selected_camera = context.object
props.use_active_camera = False
return {"FINISHED"}
@@ -1,33 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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/>.
"""Shared module-level state for the light/radiance pipeline.
These globals are populated by ExportOBJ, consumed by PrepareRadianceScene,
and read by RadianceRender. They must live in a shared module so that
all operator files can access the same state.
"""
# Collected IFC material names from the most recent export
ifc_materials: list[str] = []
# The prepared pyradiance Scene object (set by PrepareRadianceScene, read by RadianceRender)
scene = None
# Info about exported linked models: list of (obj_path, mtl_path, link_matrix_4x4)
linked_model_exports: list[tuple[str, str, list[list[float]]]] = []
-148
View File
@@ -1,148 +0,0 @@
# Bonsai - OpenBIM Blender Add-on
# Copyright (C) 2020, 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 webbrowser
from datetime import datetime
from math import radians
from typing import TYPE_CHECKING
import bpy
import ifcopenshell.util.geolocation
import requests
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
class ImportTrueNorth(bpy.types.Operator):
bl_idname = "bim.import_true_north"
bl_label = "Import True North"
bl_description = "Imports the True North from your IFC geometric context"
bl_options = {"REGISTER", "UNDO"}
@classmethod
def poll(cls, context):
if not tool.Ifc.get():
return False
if not SolarData.is_loaded:
SolarData.load()
return SolarData.data["true_north"] is not None
def execute(self, context):
props = tool.Blender.get_solar_props()
for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False):
if not context.TrueNorth:
continue
value = context.TrueNorth.DirectionRatios
props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2]))
return {"FINISHED"}
class ImportLatLong(bpy.types.Operator):
bl_idname = "bim.import_lat_long"
bl_label = "Import Latitude / Longitude"
bl_description = "Imports the latitude / longitude from an IfcSite"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
site = tool.Ifc.get().by_id(int(props.sites))
if site.RefLatitude and site.RefLongitude:
props.latitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLatitude)
props.longitude = ifcopenshell.util.geolocation.dms2dd(*site.RefLongitude)
return {"FINISHED"}
class MoveSunPathTo3DCursor(bpy.types.Operator):
bl_idname = "bim.move_sun_path_to_3d_cursor"
bl_label = "Move Sun Path To 3D Cursor"
bl_description = "Shifts the visualisation of the Sun Path to the 3D cursor"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
assert context.scene
props.sun_path_origin = context.scene.cursor.location
tool.Blender.update_viewport()
return {"FINISHED"}
class ViewFromSun(bpy.types.Operator):
bl_idname = "bim.view_from_sun"
bl_label = "View From Sun"
bl_description = "Views your model as if you were looking from the perspective of the sun"
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
if not (camera := bpy.data.objects.get("SunPathCamera")):
camera = bpy.data.objects.new("SunPathCamera", bpy.data.cameras.new("SunPathCamera"))
assert isinstance(camera.data, bpy.types.Camera)
assert context.scene
camera.data.type = "ORTHO"
camera.data.ortho_scale = 100 # The default of 6m is too small
context.scene.collection.objects.link(camera)
tool.Blender.activate_camera(camera)
props = tool.Blender.get_solar_props()
props.hour = props.hour # Just to refresh camera position
return {"FINISHED"}
class LightPickCoordinates(bpy.types.Operator):
bl_idname = "bim.light_pick_coordinates"
bl_label = "Pick Coordinates"
bl_description = (
"Open web browser with Google Maps to pick coordinates (Right Mouse Click in maps to copy selected location).\n\n"
"ALT+Click to insert current location based on the current IP-address (using ip-api.com)."
)
bl_options = {"REGISTER", "UNDO"}
use_current_location: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
if TYPE_CHECKING:
use_current_location: bool
def invoke(self, context, event):
if event.alt:
self.use_current_location = True
return self.execute(context)
def execute(self, context):
props = tool.Blender.get_solar_props()
if not self.use_current_location:
zoom = 13.5
url = f"https://www.google.com/maps/@{props.latitude},{props.longitude},{zoom}z"
webbrowser.open(url)
return {"FINISHED"}
response = requests.get("http://ip-api.com/json/")
data = response.json()
props.latitude = data["lat"]
props.longitude = data["lon"]
return {"FINISHED"}
class LightSetTimeToNow(bpy.types.Operator):
bl_idname = "bim.light_set_time_to_now"
bl_label = "Now"
bl_description = "Set time to current local time."
bl_options = {"REGISTER", "UNDO"}
def execute(self, context):
props = tool.Blender.get_solar_props()
props.set_from_datetime(datetime.now())
return {"FINISHED"}
+43 -235
View File
@@ -22,97 +22,52 @@ from typing import TYPE_CHECKING
import bpy
import bonsai.tool as tool
from bonsai.bim.helper import prop_with_search
from bonsai.bim.module.light.data import SolarData
# ---------------------------------------------------------------------------
# Root panel (replaces the old "Radiance Exporter" nested panel)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_exporter(bpy.types.Panel):
"""Creates a Panel in the render properties window"""
bl_label = "Radiance Exporter"
bl_idname = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_tab_lighting"
bl_options = {"HIDE_HEADER"}
def draw(self, context):
pass
# ---------------------------------------------------------------------------
# 1. Scene Setup
# ---------------------------------------------------------------------------
class BIM_PT_radiance_scene_setup(bpy.types.Panel):
bl_label = "Scene Setup"
bl_idname = "BIM_PT_radiance_scene_setup"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
assert self.layout
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
if tool.Ifc.get():
row = self.layout.row()
row.prop(props, "should_load_from_memory")
if not tool.Ifc.get() or not props.should_load_from_memory:
row = self.layout.row(align=True)
row.prop(props, "ifc_file")
row = layout.row()
row.prop(props, "output_dir")
layout.separator()
row = layout.row()
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row = layout.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row = layout.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
# ---------------------------------------------------------------------------
# 2. Materials
# ---------------------------------------------------------------------------
class BIM_PT_radiance_materials(bpy.types.Panel):
bl_label = "Materials"
bl_idname = "BIM_PT_radiance_materials"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
layout.label(text="Unmapped materials default to white", icon="INFO")
layout.label(text="Info: Unmapped materials default to white")
row = layout.row()
row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index")
row.operator("radiance.open_spectraldb", text="", icon="WORLD")
row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon
if len(props.materials) > 0:
col = layout.column(align=True)
prop_with_search(col, props, "category")
col.prop(props, "category")
if props.category:
prop_with_search(col, props, "subcategory")
col.prop(props, "subcategory")
if 0 <= props.active_material_index < len(props.materials):
if props.active_material_index >= 0 and props.active_material_index < len(props.materials):
active_material = props.materials[props.active_material_index]
if active_material.category and active_material.subcategory:
layout.label(
text=f"Mapped: {active_material.name} -> {active_material.category} - {active_material.subcategory}"
text=f"Mapped: {active_material.name} to {active_material.category} - {active_material.subcategory}"
)
else:
layout.label(text=f"Select category and subcategory for: {active_material.name}")
@@ -123,95 +78,28 @@ class BIM_PT_radiance_materials(bpy.types.Panel):
row = layout.row()
row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials")
layout.separator()
# ---------------------------------------------------------------------------
# 3. Lighting (Environment + IES)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_lighting(bpy.types.Panel):
bl_label = "Lighting"
bl_idname = "BIM_PT_radiance_lighting"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Environment
box = layout.box()
box.label(text="Environment", icon="WORLD")
row = box.row()
row.prop(props, "use_hdr")
row = box.row()
row.prop(props, "use_sun")
if props.use_sun:
box.prop(props, "sky_condition")
row = box.row()
row.prop(props, "ground_reflectance")
row = box.row()
row.prop(props, "turbidity")
row = layout.row()
layout.label(text="Step 1: Export geometry for simulation")
row = layout.row()
row.operator("export_scene.radiance", text="Export Geometry for Simulation")
layout.separator()
# IES Light Fixtures
box = layout.box()
box.label(text="IES Light Fixtures", icon="LIGHT_POINT")
box.label(text="Camera Settings")
row = box.row()
row.template_list("MATERIAL_UL_ies_lights", "", props, "ies_lights", props, "active_ies_light_index")
col = row.column(align=True)
col.operator("radiance.add_ies_light", text="", icon="ADD")
row.prop(props, "use_active_camera")
if not props.use_active_camera:
row = box.row()
row.prop(props, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
if len(props.ies_lights) > 0 and 0 <= props.active_ies_light_index < len(props.ies_lights):
active_light = props.ies_lights[props.active_ies_light_index]
col = box.column(align=True)
row = col.row()
row.prop(active_light, "use_collection", text="Target Collection", icon="OUTLINER_COLLECTION")
row = col.row()
if active_light.use_collection:
row.prop(active_light, "target_collection", text="Collection")
if active_light.target_collection:
empties = [o for o in active_light.target_collection.all_objects if o.type == "EMPTY"]
row = col.row()
row.label(text=f"{len(empties)} empty object(s) in collection", icon="INFO")
else:
row.prop(active_light, "target_object", text="Object")
row = col.row()
row.label(text="Rotation Z")
row.prop(active_light, "rotation_z", text="")
row = col.row()
row.prop(active_light, "lamp_color")
split = col.split(factor=0.5)
split.prop(active_light, "multiply_factor")
split.prop(active_light, "radius")
# ---------------------------------------------------------------------------
# 4. Render Settings
# ---------------------------------------------------------------------------
class BIM_PT_radiance_render_settings(bpy.types.Panel):
bl_label = "Render Settings"
bl_idname = "BIM_PT_radiance_render_settings"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
row = box.row(align=True)
row.label(text="Resolution")
row.prop(props, "radiance_resolution_x", text="X")
row.prop(props, "radiance_resolution_y", text="Y")
row = layout.row()
row.prop(props, "radiance_quality")
@@ -222,9 +110,6 @@ class BIM_PT_radiance_render_settings(bpy.types.Panel):
row = layout.row()
row.prop(props, "radiance_variability")
row = layout.row()
row.prop(props, "ambient_bounces")
layout.separator()
row = layout.row()
@@ -232,94 +117,20 @@ class BIM_PT_radiance_render_settings(bpy.types.Panel):
row = layout.row()
row.prop(props, "output_file_format")
# ---------------------------------------------------------------------------
# 5. Pipeline (Steps 1-4 + Cleanup)
# ---------------------------------------------------------------------------
class BIM_PT_radiance_pipeline(bpy.types.Panel):
bl_label = "Pipeline"
bl_idname = "BIM_PT_radiance_pipeline"
bl_space_type = "PROPERTIES"
bl_region_type = "WINDOW"
bl_context = "scene"
bl_parent_id = "BIM_PT_radiance_exporter"
def draw(self, context):
layout = self.layout
props = tool.Blender.get_radiance_exporter_props()
# Step 1
box = layout.box()
row = box.row()
row.label(text="Step 1: Export Geometry")
row = box.row()
if props.is_exporting:
row.label(text="Exporting...", icon="SORTTIME")
else:
row.operator("export_scene.radiance", text="Export Geometry")
# Step 2
box = layout.box()
row = box.row()
row.label(text="Step 2: Prepare Scene")
row = box.row()
if props.is_preparing:
row.label(text="Preparing scene...", icon="SORTTIME")
else:
row.operator("scene.prepare_radiance", text="Prepare Scene")
# Step 3
box = layout.box()
row = box.row()
row.label(text="Step 3: Render")
row = box.row()
if props.is_rendering:
row.label(text="Rendering...", icon="RENDER_STILL")
else:
row.operator("render_scene.radiance", text="Radiance Render")
# Step 4: False Color
box = layout.box()
row = box.row()
row.label(text="Step 4: False Color Analysis")
row = box.row()
row.prop(props, "radiance_bin_dir")
row = box.row()
row.prop(props, "false_color_label")
split = box.split(factor=0.5)
split.prop(props, "false_color_scale")
split.prop(props, "false_color_steps")
row = box.row()
row.label(text="Output Name")
row.prop(props, "false_color_output_name", text="")
row = box.row()
row.prop(props, "false_color_contour_lines")
if props.false_color_contour_lines:
row = box.row()
row.prop(props, "false_color_contour_mode")
row = box.row()
row.operator("render_scene.false_color_radiance", text="Generate False Color Image")
layout.separator()
# Cleanup
row = layout.row()
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
row.prop(props, "use_hdr")
if props.use_hdr:
row = layout.row()
row.prop(props, "choose_hdr_image")
# ---------------------------------------------------------------------------
# Solar Panel (unchanged)
# ---------------------------------------------------------------------------
row = layout.row()
layout.label(text="Step 2: Run the simulation")
row = layout.row()
row.operator("render_scene.radiance", text="Radiance Render")
row.enabled = not props.is_exporting
class BIM_PT_solar(bpy.types.Panel):
@@ -437,10 +248,7 @@ class BIM_PT_solar(bpy.types.Panel):
row = self.layout.row()
sun_props = tool.Blender.get_sun_props()
assert sun_props
if sun_props.sun_object is not None and sun_props.sun_object.data is not None:
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
else:
row.label(text="Sun object not found. Toggle shadow mode to recreate.", icon="ERROR")
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
row = self.layout.row(align=True)
row.operator("bim.view_from_sun", icon="LIGHT_HEMI")
@@ -102,6 +102,7 @@ class MaterialsData:
if (style_name := s.Name) is not None
]
results = natsorted(results, key=lambda i: i[1])
results.insert(0, ("-", "No Surface Style", ""))
return results
@classmethod
@@ -210,15 +210,14 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_material_to_selected"
bl_label = "Assign Material To Selected"
bl_description = (
"Assign currently selected material in Materials UI to the selected objects.\n"
"Occurrences automatically get usages for layer/profile sets.\n\n"
"ALT+CLICK to assign without a usage."
"Assign currently selected material in Materials UI to the selected objects.\n\n"
"ALT+CLICK to assign material as a usage."
)
bl_options = {"REGISTER", "UNDO"}
material: bpy.props.IntProperty(name="Material IFC ID")
should_auto_assign_usage: bpy.props.BoolProperty(
name="Auto Assign Usage",
default=True,
assign_as_usage: bpy.props.BoolProperty(
name="Assign Material As A Usage",
default=False,
options={"SKIP_SAVE"},
)
@@ -231,19 +230,25 @@ class AssignMaterialToSelected(bpy.types.Operator, tool.Ifc.Operator):
def invoke(self, context, event):
if event.type == "LEFTMOUSE" and event.alt:
self.should_auto_assign_usage = False
material_class = tool.Ifc.get().by_id(self.material).is_a()
if material_class not in ("IfcMaterialProfileSet", "IfcMaterialLayerSet"):
self.report({"ERROR"}, f"{material_class} cannot be assigned as a usage.")
return {"CANCELLED"}
self.assign_as_usage = True
return self.execute(context)
def _execute(self, context):
material = tool.Ifc.get().by_id(self.material)
objects = tool.Blender.get_selected_objects()
material_type = material.is_a()
if self.assign_as_usage:
material_type += "Usage"
core.assign_material(
tool.Ifc,
tool.Material,
material_type=material.is_a(),
material_type=material_type,
objects=objects,
material=material,
should_auto_assign_usage=self.should_auto_assign_usage,
)
@@ -609,25 +614,29 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
attributes=attributes,
)
layer_sets_to_regenerate = set()
slab_planer = slab.DumbSlabPlaner()
wall_objs = []
for obj in objects:
obj_element = tool.Ifc.get_entity(obj)
obj_material_usage = ifcopenshell.util.element.get_material(obj_element)
if obj_material_usage and obj_material_usage.is_a("IfcMaterialLayerSetUsage"):
obj_material_usage.OffsetFromReferenceLine = material.OffsetFromReferenceLine
obj_material_usage.DirectionSense = material.DirectionSense
obj_material_usage.ReferenceExtent = material.ReferenceExtent
layer_sets_to_regenerate.add(obj_material_usage.ForLayerSet)
# Save custom offset to BBIM_MaterialLayer pset
tool.Model.save_custom_offset_to_pset(obj_element, obj)
for layer_set in layer_sets_to_regenerate:
wall.DumbWallPlaner().regenerate_from_layer_set(layer_set)
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
# Targeted regeneration: only update this element's geometry, not
# all elements sharing the layer set (which would corrupt unrelated instances).
if obj_material_usage.LayerSetDirection == "AXIS3":
slab_planer.regenerate_from_occurence(obj_element, obj_material_usage)
elif obj_material_usage.LayerSetDirection == "AXIS2":
wall_objs.append(obj)
if wall_objs:
tool.Model.recalculate_walls(wall_objs)
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
if "CardinalPoint" in attributes:
@@ -717,11 +726,7 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
self.props.material_set_item_material = str(material_set_item.Material.id())
self.props.material_set_item_attributes.clear()
bonsai.bim.helper.import_attributes(
material_set_item,
self.props.material_set_item_attributes,
callback=self.import_attributes_callback,
)
bonsai.bim.helper.import_attributes(material_set_item, self.props.material_set_item_attributes)
if material_set_item.is_a("IfcMaterialProfile"):
if material_set_item.Profile and material_set_item.Profile.ProfileName:
@@ -729,29 +734,6 @@ class EnableEditingMaterialSetItem(bpy.types.Operator):
return {"FINISHED"}
def import_attributes_callback(
self, name: str, prop: Union["Attribute", None], data: dict[str, Any]
) -> None | Literal[True]:
if data["type"] != "IfcMaterialLayer" or name != "IsVentilated" or not prop:
return None
# Keep null semantics unchanged on export, but avoid an empty UI selection.
prop.data_type = "enum"
prop.special_type = "LOGICAL"
prop.enum_items = json.dumps(("TRUE", "FALSE", "UNKNOWN"))
value = data[name]
if value == "UNKNOWN":
prop.enum_value = "UNKNOWN"
elif value is None:
# Keep visible default as FALSE, but preserve null semantics on save.
prop.enum_value = "FALSE"
prop.is_null = True
else:
prop.enum_value = "TRUE" if value else "FALSE"
return True
class DisableEditingMaterialSetItem(bpy.types.Operator):
bl_idname = "bim.disable_editing_material_set_item"
+6 -11
View File
@@ -118,17 +118,12 @@ class BIM_PT_materials(Panel):
row.operator("bim.edit_material", text="Save Material", icon="CHECKMARK").material = ifc_definition_id
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
elif self.props.editing_material_type == "STYLE":
if MaterialsData.data["styles"]:
row = self.layout.row(align=True)
row.prop(self.props, "contexts", text="")
prop_with_search(row, self.props, "styles", text="")
row = self.layout.row(align=True)
row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
else:
row = self.layout.row(align=True)
row.label(text="No Styles Found")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
row = self.layout.row(align=True)
row.prop(self.props, "contexts", text="")
prop_with_search(row, self.props, "styles", text="")
row = self.layout.row(align=True)
row.operator("bim.edit_material_style", text="Assign Style", icon="CHECKMARK")
row.operator("bim.disable_editing_material", text="", icon="CANCEL")
class BIM_PT_object_material(Panel):
@@ -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
+1 -1
View File
@@ -82,7 +82,7 @@ def add_object(self: "BIM_OT_add_object", context: bpy.types.Context) -> None:
class BIM_OT_add_object(Operator, tool.Ifc.Operator):
bl_idname = "bim.add_grid"
bl_idname = "mesh.add_grid"
bl_label = "Grid"
bl_description = "Add IfcGrid."
bl_options = {"REGISTER", "UNDO"}
+2 -2
View File
@@ -227,7 +227,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
if not all(is_parallel12, is_parallel13, is_parallel21, is_parallel23):
fitting_type = "WYE"
if not fitting_type:
@@ -903,7 +903,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
radius: bpy.props.FloatProperty(
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
"Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
)
def _execute(self, context):
+138 -29
View File
@@ -151,11 +151,29 @@ class FilledOpeningGenerator:
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
assert representation
representation = ifcopenshell.util.representation.resolve_representation(representation)
else:
representation = self.generate_opening_from_filling(
filling, filling_obj, opening_thickness_si=opening_thickness_si
)
# Check if mapped representation - PRESERVE the mapping structure
if (
representation.RepresentationType == "MappedRepresentation"
and len(representation.Items) == 1
and representation.Items[0].is_a("IfcMappedItem")
):
# Store the existing RepresentationMap to reuse it
existing_mapping_source = representation.Items[0].MappingSource
reuse_mapped_representation = True
else:
representation = ifcopenshell.util.representation.resolve_representation(representation)
if not reuse_mapped_representation:
# Check for library template before generating from filling
template_rep = self.get_opening_template_from_type(filling)
if template_rep:
representation = template_rep
else:
representation = self.generate_opening_from_filling(
filling, filling_obj, opening_thickness_si=opening_thickness_si
)
# Create mapped representation
if reuse_mapped_representation:
@@ -229,38 +247,109 @@ class FilledOpeningGenerator:
voided_element = opening.VoidsElements[0].RelatingBuildingElement
opening_rep = ifcopenshell.util.representation.get_representation(opening, "Model", "Body", "MODEL_VIEW")
# ALWAYS preserve the existing opening representation (Tessellation, SweptSolid, etc.)
preserved_representation = None
if opening_rep:
if (
opening_rep.RepresentationType == "MappedRepresentation"
and len(opening_rep.Items) == 1
and opening_rep.Items[0].is_a("IfcMappedItem")
):
# For mapped representations, copy the underlying representation
preserved_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(),
opening_rep.Items[0].MappingSource.MappedRepresentation,
exclude=["IfcGeometricRepresentationContext"],
)
else:
# For direct representations (non-mapped), copy them too
preserved_representation = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), opening_rep, exclude=["IfcGeometricRepresentationContext"]
)
ifcopenshell.api.geometry.unassign_representation(tool.Ifc.get(), product=opening, representation=opening_rep)
ifcopenshell.api.geometry.remove_representation(tool.Ifc.get(), representation=opening_rep)
existing_opening_occurrence = self.get_existing_opening_occurrence_if_any(filling)
# Priority order for choosing representation:
# 1. Existing occurrence with MappedRepresentation (preserve mapping!)
# 2. Library template with Tessellation
# 3. Preserved representation from old opening (maintain user's work)
# 4. Generate from filling (last resort)
representation_to_use = None
reuse_mapped_representation = False
existing_mapping_source = None
if existing_opening_occurrence:
representation = ifcopenshell.util.representation.get_representation(
existing_opening_occurrence, "Model", "Body", "MODEL_VIEW"
)
representation = ifcopenshell.util.representation.resolve_representation(representation)
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
else:
if (
representation
and representation.RepresentationType == "MappedRepresentation"
and len(representation.Items) == 1
and representation.Items[0].is_a("IfcMappedItem")
):
# PRESERVE the mapped structure - reuse the same RepresentationMap
existing_mapping_source = representation.Items[0].MappingSource
reuse_mapped_representation = True
else:
representation_to_use = ifcopenshell.util.representation.resolve_representation(representation)
if not representation_to_use and not reuse_mapped_representation:
template_rep = self.get_opening_template_from_type(filling)
if template_rep and template_rep.RepresentationType == "Tessellation":
representation_to_use = template_rep
if not representation_to_use and not reuse_mapped_representation and preserved_representation:
representation_to_use = preserved_representation
if not representation_to_use and not reuse_mapped_representation:
opening_obj = tool.Ifc.get_object(opening)
if opening_obj:
tool.Ifc.unlink(element=opening)
tool.Blender.remove_data_blocks([opening_obj], remove_unused_data=True)
filling_obj = tool.Ifc.get_object(filling)
representation = self.generate_opening_from_filling(filling, filling_obj)
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation
representation_to_use = self.generate_opening_from_filling(filling, filling_obj)
# Create the mapped representation
if reuse_mapped_representation:
# Reuse existing RepresentationMap - don't create a new one!
context = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Model", "Body", "MODEL_VIEW")
new_mapped_item = tool.Ifc.get().create_entity(
"IfcMappedItem",
MappingSource=existing_mapping_source,
MappingTarget=tool.Ifc.get().create_entity(
"IfcCartesianTransformationOperator3D",
Axis1=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(1.0, 0.0, 0.0)),
Axis2=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 1.0, 0.0)),
LocalOrigin=tool.Ifc.get().create_entity("IfcCartesianPoint", Coordinates=(0.0, 0.0, 0.0)),
Scale=1.0,
Axis3=tool.Ifc.get().create_entity("IfcDirection", DirectionRatios=(0.0, 0.0, 1.0)),
),
)
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
mapped_representation = tool.Ifc.get().create_entity(
"IfcShapeRepresentation",
ContextOfItems=context,
RepresentationIdentifier="Body",
RepresentationType="MappedRepresentation",
Items=[new_mapped_item],
)
else:
mapped_representation = ifcopenshell.api.geometry.map_representation(
tool.Ifc.get(), representation=representation_to_use
)
# update voided object representation or all it's parts if it's an aggregate
ifcopenshell.api.geometry.assign_representation(
tool.Ifc.get(), product=opening, representation=mapped_representation
)
# update voided object representation...
voided_elements = ifcopenshell.util.element.get_parts(voided_element) or [voided_element]
for voided_element in voided_elements:
voided_obj = tool.Ifc.get_object(voided_element)
@@ -274,6 +363,36 @@ class FilledOpeningGenerator:
representation=representation,
)
def get_opening_template_from_type(
self, filling: ifcopenshell.entity_instance
) -> Union[ifcopenshell.entity_instance, None]:
"""
Check if the filling's type has a stored opening template from library import.
"""
element_type = ifcopenshell.util.element.get_type(filling)
if not element_type:
return None
desc = element_type.Description
if not desc or "||BonsaiOpeningTemplate:" not in desc:
return None
# Extract template ID
marker = desc.split("||BonsaiOpeningTemplate:")[-1]
template_id = int(marker.split("||")[0])
try:
template_rep = tool.Ifc.get().by_id(template_id)
# Make a copy so we don't reuse the same representation instance
copied = ifcopenshell.util.element.copy_deep(
tool.Ifc.get(), template_rep, exclude=["IfcGeometricRepresentationContext"]
)
return copied
except:
return None
def generate_opening_from_filling(
self,
filling: ifcopenshell.entity_instance,
@@ -540,16 +659,6 @@ class AddBoolean(Operator, tool.Ifc.Operator):
booleans = ifcopenshell.api.geometry.add_boolean(tool.Ifc.get(), first_item, second_items, props.operator)
rep_obj = tool.Geometry.get_geometry_props().representation_obj
if booleans:
# Users typically select two top-level items and expect the
# operand to be absorbed into the boolean, not remain as a
# standalone item alongside it.
representation = tool.Geometry.get_active_representation(rep_obj)
representation = ifcopenshell.util.representation.resolve_representation(representation)
second_items_set = set(second_items)
new_items = [i for i in representation.Items if i not in second_items_set]
if new_items:
representation.Items = new_items
rep_element = tool.Ifc.get_entity(rep_obj)
tool.Model.mark_manual_booleans(rep_element, booleans)
tool.Geometry.reload_representation(rep_obj)
@@ -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
@@ -694,14 +694,10 @@ def generate_box(usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[
new_settings = settings.copy()
new_settings["context"] = box_context
new_box = ifcopenshell.api.geometry.add_representation(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
**new_settings,
)
new_box = ifcopenshell.api.geometry.add_representation(ifc_file, should_run_listeners=False, **new_settings)
ifcopenshell.api.geometry.assign_representation(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
should_run_listeners=False,
product=product,
representation=new_box,
)
+16 -40
View File
@@ -18,7 +18,7 @@
import copy
from math import atan2, degrees, pi, radians
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
from typing import Any, Literal, Optional, Union
import bpy
import ifcopenshell
@@ -49,7 +49,7 @@ ProfileFrom2PointsReturn = Union[dict[str, Any], None]
class DumbProfileGenerator:
def __init__(self, relating_type: ifcopenshell.entity_instance):
def __init__(self, relating_type):
self.relating_type = relating_type
self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
@@ -201,7 +201,7 @@ class DumbProfileGenerator:
class DumbProfileRegenerator:
def regenerate_from_profile_def(self, profile: ifcopenshell.entity_instance) -> None:
def regenerate_from_profile_def(self, profile):
self.file = tool.Ifc.get()
objs = []
if not profile:
@@ -221,7 +221,7 @@ class DumbProfileRegenerator:
for element in self.get_element_types_using_profile(profile):
tool.Model.mark_thumbnail_for_update(element)
def regenerate_from_profile(self, usecase_path: str, ifc_file: ifcopenshell.file, settings: dict[str, Any]) -> None:
def regenerate_from_profile(self, usecase_path, ifc_file, settings):
self.file = ifc_file
objs = []
profile = settings["profile"].Profile
@@ -233,7 +233,7 @@ class DumbProfileRegenerator:
objs.append(obj)
DumbProfileRecalculator().recalculate(objs)
def get_elements_using_profile(self, profile: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]:
def get_elements_using_profile(self, profile):
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -252,9 +252,7 @@ class DumbProfileRegenerator:
results.extend(rel.RelatedObjects)
return results
def get_element_types_using_profile(
self, profile: ifcopenshell.entity_instance
) -> list[ifcopenshell.entity_instance]:
def get_element_types_using_profile(self, profile):
results = []
profile_sets = [
mp.ToMaterialProfileSet[0] for mp in self.file.get_inverse(profile) if mp.is_a("IfcMaterialProfile")
@@ -271,18 +269,12 @@ 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(
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
if TYPE_CHECKING:
join_type: Literal["-", "L", "V", "T"]
join_type: bpy.props.StringProperty()
def _execute(self, context):
selected_objs = context.selected_objects
joiner = DumbProfileJoiner()
if self.join_type == "-":
if not self.join_type:
for obj in selected_objs:
joiner.unjoin(obj)
return {"FINISHED"}
@@ -634,15 +626,11 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[1] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -685,15 +673,11 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[0] = intersect
else:
plane = self.get_profile_plane(profile2, furthest_plane, z_inwards=False)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
@@ -737,9 +721,7 @@ class DumbProfileJoiner:
if connection1 == "ATEND":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[1] = intersect
else:
plane = self.get_profile_plane(
@@ -747,9 +729,7 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[1] = intersect + profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
@@ -762,9 +742,7 @@ class DumbProfileJoiner:
elif connection1 == "ATSTART":
if tool.Cad.is_x(abs(xy_angle), (0, 90, 180), tolerance=0.001) and is_orthogonal:
plane = self.get_profile_plane(profile2, furthest_plane if is_relating else closest_plane)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
self.body[0] = intersect
else:
plane = self.get_profile_plane(
@@ -772,9 +750,7 @@ class DumbProfileJoiner:
furthest_plane if is_relating else closest_plane,
z_inwards=False if is_relating else True,
)
intersect = mathutils.geometry.intersect_line_plane(
axis1[0], axis1[1], plane.translation, plane.col[2].to_3d()
)
intersect = mathutils.geometry.intersect_line_plane(*axis1, plane.translation, plane.col[2].to_3d())
max_dim = self.get_max_bound_box_dimension(profile1)
self.body[0] = intersect - profile1.matrix_world.to_quaternion() @ Vector((0, 0, max_dim))
self.clippings.append(
+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,
+13 -3
View File
@@ -277,8 +277,19 @@ class DumbSlabPlaner:
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 2 * pi, tolerance=0.001) else existing_x_angle
direction_ratios = Vector(extrusion.ExtrudedDirection.DirectionRatios)
offset_direction = direction_ratios.copy()
perpendicular_depth = thickness * abs(1 / cos(existing_x_angle))
perpendicular_offset = layer_offset * abs(1 / cos(existing_x_angle)) / self.unit_scale
# The extrusion depth needed to achieve a given perpendicular thickness depends on
# how much the extrusion direction deviates from the slab face normal (local Z).
# For an ObjectPlacement-rotated slab, extrusion_vec.z ≈ 1.0 → no scaling.
# For an ExtrudedDirection-tilted slab, extrusion_vec.z < 1.0 → scale up.
extrusion_z = abs(direction_ratios.normalized().z)
if extrusion_z > 1e-6:
perpendicular_depth = thickness / extrusion_z
perpendicular_offset = layer_offset / extrusion_z / self.unit_scale
else:
perpendicular_depth = thickness
perpendicular_offset = layer_offset / self.unit_scale
ifc_position = extrusion.Position
# Check angle and z direction to determine whether the extrusion direction is positive or negative
if (abs(existing_x_angle) < (pi / 2) and direction_ratios.z > 0) or (
@@ -301,7 +312,6 @@ class DumbSlabPlaner:
extrusion.ExtrudedDirection.DirectionRatios = tuple(direction_ratios)
extrusion.Depth = perpendicular_depth
ifc_position = extrusion.Position
position = offset_direction * perpendicular_offset
material = ifcopenshell.util.element.get_material(element)
if material:
+2 -2
View File
@@ -31,11 +31,11 @@ def calculate_quantities(usecase_path, ifc_file: ifcopenshell.file, settings):
return
task = next(e for e in ifc_file.get_inverse(element) if e.is_a("IfcTask"))
qto = ifcopenshell.api.pset.add_qto(
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities" # ty:ignore[unknown-argument]
ifc_file, should_run_listeners=False, product=task, name="Qto_TaskBaseQuantities"
)
ifcopenshell.api.pset.edit_qto(
ifc_file,
should_run_listeners=False, # ty:ignore[unknown-argument]
should_run_listeners=False,
qto=qto,
properties={
"StandardWork": ifcopenshell.util.date.ifc2datetime(element.ScheduleDuration).days,
@@ -1268,6 +1268,27 @@ class DumbWallJoiner:
bonsai.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=wall2)
return wall2
def join_Z(self, wall1, slab2):
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(slab2)
for rel in element1.ConnectedFrom:
if rel.is_a() == "IfcRelConnectsElements" and rel.Description == "TOP":
ifcopenshell.api.geometry.disconnect_element(
tool.Ifc.get(),
relating_element=rel.RelatingElement,
related_element=element1,
)
ifcopenshell.api.geometry.connect_element(
tool.Ifc.get(),
relating_element=element2,
related_element=element1,
description="TOP",
)
tool.Model.recreate_wall(element1, wall1)
def set_axis(self, wall, p1, p2):
axis = ifcopenshell.util.representation.get_context(tool.Ifc.get(), "Plan", "Axis", "GRAPH_VIEW")
builder = ifcopenshell.util.shape_builder.ShapeBuilder(tool.Ifc.get())
@@ -1312,6 +1333,29 @@ class DumbWallJoiner:
self.set_axis(element1, p1, p2)
tool.Model.recreate_wall(element1, wall1)
def join_T(self, wall1: bpy.types.Object, wall2: bpy.types.Object) -> None:
element1 = tool.Ifc.get_entity(wall1)
element2 = tool.Ifc.get_entity(wall2)
axis1 = tool.Model.get_wall_axis(wall1)
axis2 = tool.Model.get_wall_axis(wall2)
intersect = tool.Cad.intersect_edges(axis1["reference"], axis2["reference"])
if intersect:
intersect, _ = intersect
else:
return
connection = "ATEND" if tool.Cad.edge_percent(intersect, axis1["reference"]) > 0.5 else "ATSTART"
ifcopenshell.api.geometry.connect_path(
tool.Ifc.get(),
related_element=element1,
relating_element=element2,
relating_connection="ATPATH",
related_connection=connection,
description="BUTT",
)
tool.Model.recreate_wall(element1, wall1, axis1["reference"], axis1["reference"])
def connect(self, obj1: bpy.types.Object, obj2: bpy.types.Object) -> None:
wall1 = tool.Ifc.get_entity(obj1)
wall2 = tool.Ifc.get_entity(obj2)
@@ -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"]:
+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
@@ -76,7 +76,6 @@ classes = (
operator.UnlinkIfc,
operator.UnloadLink,
workspace.ExploreHotkey,
operator.GenerateUVMap,
prop.LibraryBreadcrumb,
prop.LibraryElement,
prop.FilterCategory,
+80 -151
View File
@@ -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
@@ -180,11 +182,6 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
append_all: bool
use_relative_path: bool
reload_previous_file = False
def invoke(self, context, event):
@@ -561,12 +558,8 @@ class AppendEntireLibrary(bpy.types.Operator, tool.Ifc.Operator):
class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
query: bpy.props.StringProperty(name="Query")
if TYPE_CHECKING:
query: str
@classmethod
def poll(cls, context):
return tool.Ifc.get()
@@ -598,11 +591,6 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
prop_index: bpy.props.IntProperty()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
if TYPE_CHECKING:
definition: int
prop_index: int
assume_unique_by_name: bool
file: ifcopenshell.file
@classmethod
@@ -630,6 +618,8 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if not element:
return {"FINISHED"}
if element.is_a("IfcTypeProduct"):
# Store opening template from library if it exists
self.store_opening_template_from_library(element, library_file)
self.import_type_from_ifc(element, context)
elif element.is_a("IfcProduct"):
# NOTE: Non-types are not exposed in UI directly
@@ -730,6 +720,53 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
if element.is_a("IfcSurfaceStyle") and not tool.Ifc.get_object_by_identifier(element.id()):
ifc_importer.create_style(element)
def store_opening_template_from_library(
self, element: ifcopenshell.entity_instance, library_file: ifcopenshell.file
) -> None:
"""
Find an opening representation in the library and copy it to the current file
as a template. Store the template ID on the type for later retrieval.
"""
try:
library_element = library_file.by_guid(element.GlobalId)
except:
return
# Find occurrences with openings in the library
library_occurrences = ifcopenshell.util.element.get_types(library_element)
for occurrence in library_occurrences:
if not getattr(occurrence, "FillsVoids", None):
continue
library_opening = occurrence.FillsVoids[0].RelatingOpeningElement
library_opening_rep = ifcopenshell.util.representation.get_representation(
library_opening, "Model", "Body", "MODEL_VIEW"
)
if not library_opening_rep:
continue
# Check if mapped representation
if (
library_opening_rep.RepresentationType == "MappedRepresentation"
and len(library_opening_rep.Items) == 1
and library_opening_rep.Items[0].is_a("IfcMappedItem")
):
mapped_rep = library_opening_rep.Items[0].MappingSource.MappedRepresentation
# Store ALL representation types (Tessellation, SweptSolid, etc.)
template_rep = ifcopenshell.util.element.copy_deep(
self.file, mapped_rep, exclude=["IfcGeometricRepresentationContext"]
)
# Store reference in type's Description
current_desc = element.Description or ""
element.Description = f"{current_desc}||BonsaiOpeningTemplate:{template_rep.id()}"
return
break
class EditProjectLibrary(bpy.types.Operator):
bl_idname = "bim.edit_project_library"
@@ -979,15 +1016,6 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
if TYPE_CHECKING:
filepath: str
filter_glob: str
is_advanced: bool
use_relative_path: bool
should_start_fresh_session: bool
import_without_ifc_data: bool
use_detailed_tooltip: bool
@classmethod
def description(cls, context, properties):
tooltip = cls.bl_description
@@ -1088,7 +1116,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():
@@ -1288,9 +1316,6 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
if TYPE_CHECKING:
should_select: bool
def execute(self, context):
props = tool.Project.get_project_props()
for filter_category in props.filter_categories:
@@ -1313,14 +1338,6 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
"Default query - IfcElement, but excluding IfcProxy, IfcSpatialStructureElement, IfcSpatialElement, IfcFeatureElement."
),
)
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1330,25 +1347,20 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
filter_glob: str
use_relative_path: bool
use_cache: bool
query: str
def draw(self, context):
assert self.layout
pprops = tool.Project.get_project_props()
row = self.layout.row()
row.prop(self, "use_relative_path")
row = self.layout.row()
row.prop(self, "use_cache")
row = self.layout.row()
row.label(text="False Origin Mode:")
row = self.layout.row()
row.prop(pprops, "false_origin_mode", text="")
row.prop(pprops, "false_origin_mode")
if pprops.false_origin_mode == "MANUAL":
row = self.layout.row()
row.prop(pprops, "false_origin")
row = self.layout.row()
row.prop(pprops, "project_north")
self.layout.prop(self, "query", placeholder="IfcElement")
def _execute(self, context):
start = time.time()
@@ -1381,7 +1393,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
new.ifc_definition_id = reference.id()
new.name = filepath
new.filepath = filepath
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache, query=self.query)
bpy.ops.bim.load_link(link_index=-1, use_cache=self.use_cache)
class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
@@ -1389,12 +1401,8 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unlink IFC"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def _execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
@@ -1413,12 +1421,8 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Unload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def _execute(self, context):
link = tool.Project.get_project_props().links[self.link_index]
if obj := tool.Project.get_link_empty_handle(link):
@@ -1440,14 +1444,12 @@ 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]
if TYPE_CHECKING:
link_index: int
use_cache: bool
query: str
def _execute(self, context):
self.link = tool.Project.get_project_props().links[self.link_index]
@@ -1489,20 +1491,8 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
def link_ifc(self) -> Union[set[str], None]:
blend_filepath = self.filepath_.with_suffix(".ifc.cache.blend")
h5_filepath = self.filepath_.with_suffix(".ifc.cache.h5")
json_filepath = self.filepath_.with_suffix(".ifc.cache.json")
def should_clear_cache() -> bool:
if not self.use_cache:
return True
if not blend_filepath.exists():
return False
data = json.loads(json_filepath.read_text())
# Empty 'query' - model loaded without custom query.
# Missing 'query' - model was loaded before custom queries were introduced in Bonsai.
query = data.get("query", "")
return query != self.query
if should_clear_cache():
if not self.use_cache and blend_filepath.exists():
os.remove(blend_filepath)
if not blend_filepath.exists():
@@ -1530,7 +1520,7 @@ def run():
pprops.project_north = "{pprops.project_north}"
# Use absolute path to be safe from cwd changes.
try:
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}", query={repr(self.query)})
bpy.ops.bim.load_linked_project(filepath=r"{str(self.filepath_)}")
except RuntimeError as e:
# Operator failed (returned CANCELLED with error report)
print(f"Failed to load linked project: {{e}}")
@@ -1616,12 +1606,8 @@ class ReloadLink(bpy.types.Operator):
bl_label = "Reload Link"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def execute(self, context):
bpy.ops.bim.unload_link(link_index=self.link_index)
return bpy.ops.bim.load_link(link_index=self.link_index, use_cache=False) or {"FINISHED"}
@@ -1632,12 +1618,8 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_label = "Toggle Link Selectability"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
@@ -1665,8 +1647,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")),
)
@@ -1806,12 +1788,8 @@ class SelectLinkHandle(bpy.types.Operator):
bl_label = "Select Link Handle"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
def execute(self, context):
props = tool.Project.get_project_props()
link = props.links[self.link_index]
@@ -1829,7 +1807,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
@@ -1874,13 +1852,6 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
json_version: str
json_compact: bool
should_save_as: bool
use_relative_path: bool
@classmethod
def poll(cls, context):
return tool.Ifc.get()
@@ -2029,12 +2000,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
query: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
query: str
file: ifcopenshell.file
meshes: dict[str, bpy.types.Mesh]
# Material names is derived from diffuse as in 'r-g-b-a'.
@@ -2084,17 +2049,14 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
tool.Loader.settings.context_settings = tool.Loader.create_settings()
tool.Loader.settings.gross_context_settings = tool.Loader.create_settings(is_gross=True)
if self.query:
self.elements = ifcopenshell.util.selector.filter_elements(self.file, self.query)
self.elements = set(self.file.by_type("IfcElement"))
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements |= set(self.file.by_type("IfcProxy"))
if self.file.schema == "IFC2X3":
self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
else:
self.elements = set(self.file.by_type("IfcElement"))
if self.file.schema in ("IFC2X3", "IFC4"):
self.elements |= set(self.file.by_type("IfcProxy"))
if self.file.schema == "IFC2X3":
self.elements |= set(self.file.by_type("IfcSpatialStructureElement"))
else:
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
self.elements |= set(self.file.by_type("IfcSpatialElement"))
self.elements -= set(self.file.by_type("IfcFeatureElement"))
if tool.Loader.settings.false_origin_mode == "MANUAL" and tool.Loader.settings.false_origin:
tool.Loader.set_manual_blender_offset(self.file)
@@ -2119,7 +2081,6 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
"false_origin_mode": pprops.false_origin_mode,
"false_origin": pprops.false_origin,
"project_north": pprops.project_north,
"query": self.query,
}
with open(self.json_filepath, "w") as f:
json.dump(data, f)
@@ -2419,8 +2380,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
@@ -2534,7 +2495,7 @@ class EnableCulling(bpy.types.Operator):
self.total_mousemoves = 0
self.cullable_objects = []
def modal(self, context, event) -> set["rna_enums.OperatorReturnItems"]:
def modal(self, context, event):
if not LinksData.enable_culling:
for obj in bpy.context.visible_objects:
if obj.type == "MESH" and obj.name.startswith("Ifc"):
@@ -2565,7 +2526,7 @@ class EnableCulling(bpy.types.Operator):
return {"PASS_THROUGH"}
def is_view_changed(self, context: bpy.types.Context) -> bool:
def is_view_changed(self, context):
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
vp_matrix = projection_matrix @ view_matrix
@@ -2580,7 +2541,7 @@ class EnableCulling(bpy.types.Operator):
return True
return False
def is_object_in_view(self, obj: bpy.types.Object, context: bpy.types.Context, camera_position: Vector) -> bool:
def is_object_in_view(self, obj, context, camera_position):
# Get the view matrix and the projection matrix from the active viewport
view_matrix = context.region_data.view_matrix
projection_matrix = context.region_data.window_matrix
@@ -2607,7 +2568,7 @@ class EnableCulling(bpy.types.Operator):
return False
return True
def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set["rna_enums.OperatorReturnItems"]:
def invoke(self, context, event):
LinksData.enable_culling = True
self.cullable_objects = []
for obj in bpy.context.visible_objects:
@@ -2897,10 +2858,6 @@ class IFCFileHandlerOperator(bpy.types.Operator):
directory: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"})
files: bpy.props.CollectionProperty(type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"})
if TYPE_CHECKING:
directory: str
files: list[bpy.types.OperatorFileListElement]
def invoke(self, context, event):
# Keeping code in .invoke() as we'll probably add some
# popup windows later.
@@ -2952,9 +2909,6 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
@@ -3051,9 +3005,6 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@classmethod
def poll(cls, context):
return context.space_data.type == "VIEW_3D"
@@ -3156,10 +3107,7 @@ class ClearMeasurement(bpy.types.Operator):
@classmethod
def poll(cls, context):
polyline_props = tool.Model.get_polyline_props()
if len(polyline_props.measurement_polyline) > 0:
return True
cls.poll_message_set("No measurement to clear.")
return False
return len(polyline_props.measurement_polyline) > 0
def execute(self, context):
polyline_props = tool.Model.get_polyline_props()
@@ -3263,7 +3211,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
super().invoke(context, event)
return {"RUNNING_MODAL"}
def cancel_tool(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
def cancel_tool(self, context):
context.workspace.status_text_set(text=None)
if hasattr(self, "tool_state"):
self.tool_state.plane_method = None
@@ -3271,7 +3219,7 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
tool.Blender.update_viewport()
return {"CANCELLED"}
def handle_custom_instructions(self, context: bpy.types.Context) -> None:
def handle_custom_instructions(self, context):
if len(self.selected_points) == 0:
instruction_text = "Click First Point on Image"
elif len(self.selected_points) == 1:
@@ -3286,14 +3234,14 @@ class ImageScalingTool(bpy.types.Operator, PolylineOperator):
context.workspace.status_text_set(text=instruction_text)
def calculate_distance(self) -> None:
def calculate_distance(self):
if len(self.selected_points) == 2:
point1 = self.selected_points[0]
point2 = self.selected_points[1]
distance_3d = (point2 - point1).length
self.calculated_distance = distance_3d / self.unit_scale
def apply_scaling(self, context: bpy.types.Context) -> set["rna_enums.OperatorReturnItems"]:
def apply_scaling(self, context):
if len(self.selected_points) != 2:
self.report({"ERROR"}, "Two points must be selected")
return {"CANCELLED"}
@@ -3353,9 +3301,6 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
if TYPE_CHECKING:
filepath: str
def execute(self, context):
ifc_file = self.filepath
if not ifc_file:
@@ -3388,19 +3333,3 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bpy.app.handlers.load_post.append(load_handler)
bpy.ops.wm.open_mainfile(filepath=metadata_path)
return {"FINISHED"}
class GenerateUVMap(bpy.types.Operator):
bl_idname = "bim.generate_uv_map"
bl_label = "Generate UV Map"
bl_description = "Generate UV map for selected mesh."
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
def execute(self, context):
obj = context.active_object
if not obj or not isinstance(obj.data, bpy.types.Mesh):
self.report({"ERROR"}, "No valid mesh selected.")
return {"CANCELLED"}
tool.Loader.load_generated_uv_map(obj.data)
self.report({"INFO"}, "Generated UV map for selected mesh.")
return {"FINISHED"}

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