Compare commits

...

62 Commits

Author SHA1 Message Date
Ryan Schultz ac18195361 Merge v0.8.0 into light/newUI
Resolved conflict in light/operator.py by keeping the modular
re-export architecture from light/newUI and incorporating
RADIANCE_OT_select_camera that was missed during refactoring.
Added it to render.py, registered in __init__.py, and restored
the eyedropper button in ui.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 07:59:14 -05:00
Andrej730 e6258ab4a8 Bump VERSION to 0.8.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:40:25 +05:00
Andrej730 5db65f4041 maintenance - list all things we do on release 2026-04-13 19:39:14 +05:00
Andrej730 89ce32fdfd Remove redundant docs-deployment.yml workflow
The https://github.com/IfcOpenShell/website repo already has bonsai-docs.yml workflow that does the same thing - builds Bonsai docs from the main repo and deploys to bonsaibim_org_docs, so this workflow is redundant and confusing.
2026-04-13 19:39:14 +05:00
Andrej730 763a31a31d readme: fix ifcsverchok badge filter 2026-04-13 19:38:25 +05:00
Andrej730 4b8c612647 fix ifcmcp package name inconsistency 2026-04-13 18:44:01 +05:00
Andrej730 16723d11ca ci-pyodide-wasm-release - add tag when pushing release 2026-04-13 17:45:37 +05:00
Andrej730 67238c4ac1 ci-pyodide-wasm-release - use BUILD_REPO_TOKEN 2026-04-13 17:40:02 +05:00
Andrej730 20229aa88c README.md: add pyodide-wasm-wheels tag badge 2026-04-13 16:43:20 +05:00
Andrej730 7788ae86c9 build-all.py: descriptive error for missing SSL support 2026-04-13 16:23:41 +05:00
Bruno Postle 002b7c5d6e ifcquery, ifcmcp: better bot selector syntax hints 2026-04-10 22:09:56 +01:00
Thomas Krijnen e7db239647 inverse access in schema 2026-04-10 21:46:39 +02:00
Thomas Krijnen 158756e921 arrange_polygons: settings, simplify based on growing boxes; more... 2026-04-10 21:46:39 +02:00
Andrej730 a3efa7e9ee util.element - fix IfcComplexProperty KeyError when verbose=True (#7921)
Introduced by me in b77df1892
2026-04-10 19:11:42 +05:00
Andrej730 4896946e78 ty ignore some upstream bpy stubs issues 2026-04-10 19:11:41 +05:00
Andrej730 588f365366 Remove unused ty ignores - issue is resolved upsteam in stubs 2026-04-10 19:11:41 +05:00
Andrej730 fa8770c14d ty - drop rules removed from recent version of ty 2026-04-10 19:11:41 +05:00
Andrej730 0cf831133e ci-lint - add ty type check 2026-04-10 19:11:41 +05:00
Andrej730 98338e0831 Rename ci-black-formatting workflow to ci-lint 2026-04-10 18:06:43 +05:00
Andrej730 5a3160eb62 black . 2026-04-10 18:02:47 +05:00
Andrej730 80a9df8f52 Ignore pyright warnings for bpy stubs
See https://github.com/nutti/fake-bpy-module/discussions/440
2026-04-10 18:01:19 +05:00
Andrej730 8eb0060d4a Get rid of pyright ignore reportRedeclaration noise
Welp, it was helping to point out untyped props, but it is getting too noisy now.
2026-04-10 17:55:16 +05:00
falken10vdl 51a338e4c8 Suppress reportRedeclaration in Pyright config 2026-04-10 17:49:14 +05:00
Andrej730 7169dcd053 Create ci-pyodide-wasm-release.yml 2026-04-10 17:29:46 +05:00
Andrej730 b9d4ea38b0 Script for packing pyodide wheel 2026-04-10 17:29:46 +05:00
Andrej730 c8f46cfb69 build_pyodide.sh - use emsdk from pyodide 2026-04-10 16:22:04 +05:00
Andrej730 6242251d3c Fix typo 2026-04-10 16:22:04 +05:00
Andrej730 1689960257 Maintenence - document ci-bonsai.yml update 2026-04-10 16:20:50 +05:00
dependabot[bot] c509f1d3ee Bump vite from 6.4.1 to 6.4.2 in /src/ifctester/webapp
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 16:16:33 +05:00
Dion Moult 217bfed847 Add py313 to stable build 2026-04-10 19:05:54 +10:00
Bruno Postle b4558f7f75 Fix ruff import ordering complaints 2026-04-09 01:04:14 +01:00
dependabot[bot] ebd5fe854f Bump ruff from 0.15.8 to 0.15.9
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.8 to 0.15.9.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.8...0.15.9)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:16 +10:00
dependabot[bot] 06cfd0931c Bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:10 +10:00
dependabot[bot] 90bd7d26ac Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-09 09:59:03 +10:00
Thomas Krijnen 3fbf01f446 partial revert of 24acfea 2026-04-08 13:48:23 +02:00
Bruno Postle 26a1955cba Fix compilation failure introduced in 24acfea 2026-04-07 23:34:30 +01:00
Bruno Postle 27d9cae8ff Bonsai, bump ifcmerge.exe to working version with deps
Don't leave a broken repo if ifcmerge is misinstalled.
Fix bug where only local branches could be merged.
Fix gitch where merge commits were not considered relevant.
2026-04-07 22:25:31 +01:00
Thomas Krijnen a751c1cce3 ifcchat: compaction 2026-04-07 09:46:45 +02:00
Thomas Krijnen 9d4307d343 ifcchat: Throttling of messages based on estimated token counts 2026-04-07 09:46:11 +02:00
Chirag Singh 394eb54f80 Light: Fix lint errors (unused imports, import sorting, formatting) 2026-04-03 01:40:32 +05:30
Chirag Singh f91f44cd4a Light/ Ran black formatting 2026-04-03 01:30:59 +05:30
Chirag Singh d835577750 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-04-03 01:22:37 +05:30
Chirag Singh fffb444b20 Merge v0.8.0 into light/newUI 2026-04-03 01:11:16 +05:30
Chirag Singh a3f212c021 Light: UI revamp with sub-panels, module split, falsecolor via system Radiance 2026-04-03 01:07:48 +05:30
Chirag Singh c627d59bda Light: Optimized radiance rendering workflow 2026-03-21 18:25:49 +05:30
Ryan Schultz fd13cd6c13 Fix AttributeError by removing duplicate bim.enum_property_search operator
The light module (PR #5452) defined its own EnumPropertySearch class with
bl_idname = "bim.enum_property_search", conflicting with the main
BIM_OT_enum_property_search in operator.py. The light module's version
lacked should_click_ok and other properties, so whichever class registered
last caused an AttributeError when helper.py tried to set op.should_click_ok.

Remove the duplicate EnumPropertySearch and SetEnumProperty operators from
light/operator.py and __init__.py entirely, and replace the local
prop_with_search/get_enum_items helpers in light/ui.py with an import of
the canonical prop_with_search from bonsai.bim.helper.

Generated with the assistance of an AI coding tool.
2026-03-11 17:44:47 -05:00
Ryan Schultz afd6f422ee Fix AttributeError in prop_with_search caused by duplicate bl_idname
The light module's EnumPropertySearch operator was using the same
bl_idname ("bim.enum_property_search") as the main BIM_OT_enum_property_search
in operator.py, but only defined prop_name and search_term properties,
lacking should_click_ok and others. Whichever class registered last would
overwrite the other, causing an AttributeError when helper.py tried to
set op.should_click_ok on the stripped-down version.

Renamed the light module operator to "radiance.enum_property_search" and
updated the matching call in light/ui.py.

Generated with the assistance of an AI coding tool.
2026-03-11 17:39:00 -05:00
falken10vdl bb661ce998 Add bpy import to light list module
To avoid error:
File "/home/falken10vdl/.config/blender/5.0/extensions/.local/lib/python3.11/site-packages/bonsai/bim/module/light/list.py", line 33, in <module>
class MATERIAL_UL_radiance_materials(bpy.types.UIList):
^^^
NameError: name 'bpy' is not defined

Cheers!
2026-03-11 09:42:18 +01:00
Chirag Singh f40377b93d Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-03-08 23:01:34 +05:30
Chirag Singh 30210b0bf6 Merge branch 'light/newUI' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:41:12 +05:30
Chirag Singh f7d438f759 Merge branch 'v0.8.0' of https://github.com/IfcOpenShell/IfcOpenShell into light/newUI 2026-02-10 13:37:05 +05:30
Ryan Schultz 8534abe0e0 Try this again. 2025-11-27 09:19:00 -06:00
Ryan Schultz 323d6db4d1 Revert "trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786"
This reverts commit 99e20cae64.
2025-11-27 09:14:14 -06:00
Ryan Schultz 99e20cae64 trying @falken10vdl's suggestion https://github.com/IfcOpenShell/IfcOpenShell/pull/5452#issuecomment-3552472786 2025-11-27 08:22:34 -06:00
Chirag Singh 5e10752bd7 Light: Fixed all issues 2025-11-27 18:21:45 +05:30
Chirag Singh 39839cff66 Ran balck Formatting 2025-11-15 21:43:48 +05:30
Chirag Singh a570d81fd7 Light(Radiance): Implemented IES File Mapping | Render False Color Images | Updated UI 2025-11-15 21:40:13 +05:30
Chirag Singh f6e23c0462 Light: Ran Black Formatting 2025-11-07 22:44:59 +05:30
Chirag Singh dcd87260e7 Light: Removed Comments, Added Sky render options in UI 2025-11-07 22:44:03 +05:30
Chirag Singh 7d148456c0 Light: Updated Radiance Exported code based on latest Pyradiance library update 2025-11-07 22:08:17 +05:30
Chirag Singh 55568e122d Ran black formatting 2025-11-07 21:26:26 +05:30
Chirag Singh 776112bf1d split obj generation separate from other material rad / rtm generation 2025-11-07 21:25:46 +05:30
81 changed files with 4157 additions and 1245 deletions
+6 -1
View File
@@ -24,7 +24,7 @@ jobs:
strategy:
fail-fast: false
matrix:
pyver: [py311, py312]
pyver: [py311, py312, py313]
config:
- {
name: "Windows Build",
@@ -42,6 +42,11 @@ jobs:
name: "MacOS ARM Build",
short_name: macosm1,
}
exclude:
# Python 3.13 is needed for Blender 5.1+ and Blender dropped Intel Mac support in 5.0.
- pyver: py313
config:
short_name: macos
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6 # https://github.com/actions/setup-python
@@ -1,4 +1,4 @@
name: ci-black-formatting
name: ci-lint
on:
push:
@@ -30,6 +30,7 @@ jobs:
uv tool install ruff
uv tool install black
uv tool install poethepoet
uv tool install ty
# black doesn't catch all syntax errors, so we check them explicitly.
- name: Check syntax errors
@@ -57,6 +58,13 @@ jobs:
black --diff --check . | black-codeclimate | python .github/workflows/black_to_github_annotations.py
continue-on-error: true
- name: ty check
id: ty
run: |
poe ty-venv
poe ty
continue-on-error: true
- name: Ruff check
id: ruff
run: |
@@ -105,4 +113,7 @@ jobs:
if [ "${{ steps.ruff.outcome }}" != "success" ]; then
echo "::error::Ruff check failed, see Summary or 'ruff' step for the details." && ERROR=1
fi
if [ "${{ steps.ty.outcome }}" != "success" ]; then
echo "::error::ty check failed, see 'ty check' step for the details." && ERROR=1
fi
exit $ERROR
@@ -0,0 +1,46 @@
name: Release Pyodide WASM Wheel
on:
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout IfcOpenShell
uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build wheel
working-directory: pyodide
run: uv run pack_wheel.py --build
- name: Find wheel
id: wheel
run: |
WHEEL=$(ls pyodide/dist/ifcopenshell-*.whl)
echo "path=$WHEEL" >> $GITHUB_OUTPUT
echo "name=$(basename $WHEEL)" >> $GITHUB_OUTPUT
- name: Checkout wasm-wheels
uses: actions/checkout@v6
with:
repository: IfcOpenShell/wasm-wheels
path: wasm-wheels
token: ${{ secrets.BUILD_REPO_TOKEN }}
- name: Commit and push wheel to wasm-wheels
run: |
WHEEL_NAME="${{ steps.wheel.outputs.name }}"
cp "${{ steps.wheel.outputs.path }}" "wasm-wheels/$WHEEL_NAME"
cd wasm-wheels
git config user.name "IfcOpenBot"
git config user.email "ifcopenbot@ifcopenshell.org"
git add "$WHEEL_NAME"
git commit -m "Add $WHEEL_NAME"
VERSION=$(cat ../VERSION)
git tag "v${VERSION}"
git push origin main
git push origin "v${VERSION}"
-36
View File
@@ -1,36 +0,0 @@
name: Build and Deploy Stable Documentation
on:
workflow_dispatch: # Manual trigger
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install dependencies
run: |
cd src/bonsai/docs
pip install -r requirements.txt # Run pip install from the docs directory
- name: Build documentation
run: |
cd src/bonsai/docs
make html
- name: Deploy to GitHub Pages (Stable)
uses: peaceiris/actions-gh-pages@v4
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: IfcOpenShell/bonsaibim_org_docs
publish_branch: main
cname: docs.bonsaibim.org
publish_dir: src/bonsai/docs/_build/html
+2 -2
View File
@@ -32,7 +32,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: IfcOpenShell/aichat_ifcopenshell_org_static_html
ref: gh-pages
@@ -42,7 +42,7 @@ jobs:
run: |
rsync -av --delete --exclude='.git/' src/ifcchat/ output/
- name: Setup Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Download wheels
@@ -32,7 +32,7 @@ jobs:
submodules: recursive
fetch-depth: 0
- name: Checkout intermediate Pages repo
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
repository: IfcOpenShell/wasm_ifcopenshell_org_static_html
ref: gh-pages
+3 -3
View File
@@ -53,11 +53,11 @@ Contents
| [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/ifcmcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcmcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [Pyodide WASM Wheels](https://github.com/IfcOpenShell/wasm-wheels#pyodide-test-wheels) |
| [ifcmcp](https://docs.ifcopenshell.org/ifcmcp.html) | MCP server for querying and editing IFC building models | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcopenshell-mcp?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell-mcp/) |
| [ifcopenshell-python](https://docs.ifcopenshell.org/ifcopenshell-python.html) | Python library for IFC manipulation | LGPL-3.0-or-later\* | [![Official](https://img.shields.io/badge/IfcOpenShell.org-Download-70ba35)](https://docs.ifcopenshell.org/ifcopenshell-python/installation.html) [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcopenshell-python-*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcopenshell-python&expanded=true) [![PyPI](https://img.shields.io/pypi/v/ifcopenshell?label=PyPI&color=006dad)](https://pypi.org/project/ifcopenshell/) [![Anaconda](https://img.shields.io/conda/vn/conda-forge/ifcopenshell?label=Anaconda&color=43b02a)](https://anaconda.org/conda-forge/ifcopenshell) [![Anaconda](https://img.shields.io/conda/vn/ifcopenshell/ifcopenshell?label=Anaconda-Unstable&color=43b02a)](https://anaconda.org/ifcopenshell/ifcopenshell) [![Docker](https://img.shields.io/docker/pulls/aecgeeks/ifcopenshell?label=Docker&color=1D63ED)](https://hub.docker.com/r/aecgeeks/ifcopenshell) [![AUR](https://img.shields.io/aur/version/ifcopenshell?label=AUR&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell) [![AUR Unstable](https://img.shields.io/aur/version/ifcopenshell-git?label=AUR-Unstable&color=1793d1)](https://aur.archlinux.org/packages/ifcopenshell-git) [![Pyodide WASM Wheels tag](https://img.shields.io/github/v/tag/ifcopenshell/wasm-wheels?sort=semver&label=pyodide-wasm-wheels)](https://github.com/IfcOpenShell/wasm-wheels) |
| [ifcpatch](https://docs.ifcopenshell.org/ifcpatch.html) | Utility to run pre-packaged scripts to manipulate IFCs | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifcpatch?label=PyPI&color=006dad)](https://pypi.org/project/ifcpatch/) |
| [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 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)
| [ifcsverchok](https://docs.ifcopenshell.org/ifcsverchok.html) | Blender Add-on for visual node programming with IFC | GPL-3.0-or-later | [![GitHub](https://img.shields.io/github/v/release/ifcopenshell/ifcopenshell?filter=ifcsverchok-*.*.*&label=GitHub&color=f6f8fa)](https://github.com/IfcOpenShell/IfcOpenShell/releases?q=ifcsverchok&expanded=true)
| [ifctester](https://docs.ifcopenshell.org/ifctester.html) | Library, CLI and webapp for IDS model auditing | LGPL-3.0-or-later | [![PyPI](https://img.shields.io/pypi/v/ifctester?label=PyPI&color=006dad)](https://pypi.org/project/ifctester/) |
The IfcOpenShell C++ codebase is split into multiple interal libraries:
+1 -1
View File
@@ -1 +1 @@
0.8.5
0.8.6
+12 -3
View File
@@ -1094,10 +1094,19 @@ if "python" in targets and not USE_CURRENT_PYTHON_VERSION and "wasm" not in flag
f"http://www.python.org/ftp/python/{PYTHON_VERSION}/",
f"Python-{PYTHON_VERSION}.tgz",
)
python_bin = INSTALL_DIR / f"python-{PYTHON_VERSION}" / "bin" / "python3"
python_install = INSTALL_DIR / f"python-{PYTHON_VERSION}"
python_bin = python_install / "bin" / "python3"
# `_ssl` module is present -> we will be able to install `numpy` later
# to verify IfcOpenShell installation
run([str(python_bin), "-c", "import _ssl"])
try:
run([str(python_bin), "-c", "import _ssl"])
except RuntimeError:
print(
"ERROR: Python was built without SSL support (_ssl module is missing). "
f"To fix this: remove the installed Python at {python_install}; "
"install OpenSSL development libraries and re-run."
)
raise
if MAC_CROSS_COMPILE_INTEL:
assert original_path
@@ -1515,7 +1524,7 @@ if "IfcOpenShell-Python" in targets:
)
# Copy setup.py where pyodide build system expects it.
shutil.copy(REPO_PATH / "pyodide" / "setup.py", REPO_PATH)
# Empty pyproject so it's contents won't affect the resulting wheelthe the
# Empty pyproject so it's contents won't affect the resulting wheel
# otherwise the wheel will use version and dependencies from toml, not setup.py.
(REPO_PATH / "pyproject.toml").write_text("")
+3 -10
View File
@@ -14,18 +14,11 @@ 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
# Emscripten doesn't come with xbuildenv.
if [ ! -d emsdk ]; then
git clone https://github.com/emscripten-core/emsdk
fi
pushd emsdk
PYODIDE_EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version)
./emsdk install ${PYODIDE_EMSCRIPTEN_VERSION}
./emsdk activate ${PYODIDE_EMSCRIPTEN_VERSION}
source emsdk_env.sh
EMSDK_ROOT=$(pyodide config get emscripten_dir)
source ${EMSDK_ROOT}/emsdk_env.sh
which emcc
popd
mkdir -p packages/ifcopenshell
VERSION=`cat IfcOpenShell/VERSION`
+232
View File
@@ -0,0 +1,232 @@
#
# /// script
# # Latest Pyodide build env versions are listed here:
# # https://pyodide.github.io/pyodide/api/pyodide-cross-build-environments.json
# # https://github.com/pyodide/pyodide-build/blob/main/pyodide_build/xbuildenv_releases.py
# requires-python = "==3.13.2"
# dependencies = [
# "requests",
# "setuptools",
# ]
# ///
"""
Pack an IfcOpenShell WASM wheel using Pyodide build system.
Usage:
uv run make_wheel.py # Show this help
uv run make_wheel.py --build # Build wheel
uv run make_wheel.py --clean # Clean build artifacts and exit
"""
import argparse
import os
import re
import shutil
import subprocess
import time
import zipfile
from pathlib import Path
from urllib.parse import quote
import requests
# Get repo root (parent of this script's parent directory)
REPO_ROOT = Path(__file__).parent.parent
PYODIDE_DIR = REPO_ROOT / "pyodide"
BUILD_DIR = PYODIDE_DIR / "build"
# Hardcoded path (Windows packing workaround with --dev flag)
PYODIDE_BUILD = Path(r"L:\Projects\Github\pyodide-build")
# Wheel platform tag (from PYODIDE_EMSCRIPTEN_VERSION in pyodide-build/Makefile.envs)
WHEEL_PLATFORM_TAG = "emscripten_4_0_9_wasm32"
# Location where ifcopenshell will be extracted
IFCOPENSHELL_DIR = PYODIDE_DIR / "ifcopenshell"
class WheelBuilder:
@staticmethod
def extract_ifcopenshell_from_git(dst: Path) -> None:
"""Extract ifcopenshell directory from git repo into destination."""
Tools.rmrf(dst)
print(f"Extracting ifcopenshell from git to {dst}...")
# Use git ls-files piped to git checkout-index to avoid copying
# untracked or ignored files from the actual repo.
ls_proc = subprocess.Popen(
["git", "ls-files", "-z", "src/ifcopenshell-python/ifcopenshell"],
cwd=REPO_ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
checkout_proc = subprocess.Popen(
["git", "checkout-index", "-z", "--prefix", "pyodide/", "--stdin"],
cwd=REPO_ROOT,
stdin=ls_proc.stdout,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert ls_proc.stdout is not None
ls_proc.stdout.close()
checkout_proc.communicate()
if checkout_proc.returncode != 0:
assert checkout_proc.stderr is not None
raise RuntimeError(f"Failed to extract: {checkout_proc.stderr.decode()}")
# Move src/ifcopenshell-python/ifcopenshell to ifcopenshell.
temp_src = PYODIDE_DIR / "src" / "ifcopenshell-python" / "ifcopenshell"
shutil.move(temp_src, dst)
# Clean up temporary src directory.
Tools.rmrf(PYODIDE_DIR / "src")
print("✓ Extracted ifcopenshell from git")
@staticmethod
def get_wheel_url(makefile_path: Path) -> str:
"""Get S3 wheel URL based on BINARY_VERSION and BUILD_COMMIT from Makefile."""
def parse_makefile_vars() -> dict[str, str]:
content = makefile_path.read_text()
vars: dict[str, str] = {}
for match in re.finditer(r"^(BINARY_VERSION|BUILD_COMMIT):=(.+)$", content, re.MULTILINE):
vars[match.group(1)] = match.group(2).strip()
return vars
vars: dict[str, str] = parse_makefile_vars()
binary_version = vars["BINARY_VERSION"]
build_commit = vars["BUILD_COMMIT"]
filename = f"ifcopenshell-{binary_version}+{build_commit}-cp313-cp313-pyodide_2025_0_wasm32.whl"
encoded_filename = quote(filename, safe="")
return f"https://s3.amazonaws.com/ifcopenshell-builds/{encoded_filename}"
@staticmethod
def download_and_extract_so(url: str, build_dir: Path) -> tuple[Path, Path]:
"""Download wheel from URL and extract .so and .py files."""
py_wrapper_filename = "ifcopenshell_wrapper.py"
build_dir.mkdir(parents=True, exist_ok=True)
wheel_path = build_dir / url.rsplit("/", 1)[-1]
if wheel_path.exists():
print(f"Using cached wheel: {wheel_path}")
else:
print(f"Downloading {url}...")
response = requests.get(url)
response.raise_for_status()
wheel_path.write_bytes(response.content)
print("Extracting _ifcopenshell_wrapper files...")
with zipfile.ZipFile(wheel_path) as zf:
so_files = [f for f in zf.namelist() if f.endswith(".so")]
py_files = [f for f in zf.namelist() if f.endswith(py_wrapper_filename)]
assert so_files, "No .so file found in wheel"
assert py_files, f"No {py_wrapper_filename} file found in wheel"
so_file = so_files[0]
so_dst = build_dir / Path(so_file).name
so_dst.write_bytes(zf.read(so_file))
py_file = py_files[0]
py_dst = build_dir / Path(py_file).name
py_dst.write_bytes(zf.read(py_file))
return so_dst, py_dst
class Tools:
@staticmethod
def run(
cmd: list[str],
cwd: Path | None = None,
) -> None:
print(f"$ {' '.join(cmd)}")
subprocess.check_call(cmd, cwd=cwd)
@staticmethod
def create_symlink(dst: Path, src: Path) -> None:
Tools.rmrf(dst)
dst.symlink_to(src)
@staticmethod
def rmrf(path: Path) -> None:
if path.exists() or path.is_symlink():
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
def clean() -> None:
"""Remove build artifacts."""
paths_to_remove = (
BUILD_DIR,
PYODIDE_DIR / ".pyodide_build",
PYODIDE_DIR / "dist",
PYODIDE_DIR / "ifcopenshell.egg-info",
PYODIDE_DIR / "src",
IFCOPENSHELL_DIR,
)
for path in paths_to_remove:
if path.exists() or path.is_symlink():
print(f"Removing {path}...")
Tools.rmrf(path)
print("✓ Clean complete")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, add_help=False)
parser.add_argument("--build", action="store_true", help="Build the wheel")
parser.add_argument("--clean", action="store_true", help="Clean build folder")
parser.add_argument(
"--dev",
action="store_true",
help="Use editable pyodide-build from hardcoded path (Windows packing workaround)",
)
args = parser.parse_args()
if not args.build and not args.clean:
print(__doc__)
return
if args.clean:
clean()
return
start_time = time.time()
WheelBuilder.extract_ifcopenshell_from_git(IFCOPENSHELL_DIR)
print("Downloading and extracting _ifcopenshell_wrapper files...")
makefile = REPO_ROOT / "src" / "ifcopenshell-python" / "Makefile"
wheel_url = WheelBuilder.get_wheel_url(makefile)
so_file, py_file = WheelBuilder.download_and_extract_so(wheel_url, BUILD_DIR)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(so_file).name, so_file)
Tools.create_symlink(IFCOPENSHELL_DIR / Path(py_file).name, py_file)
print("Installing pyodide-build...")
if args.dev:
Tools.run(["uv", "pip", "install", "-e", str(PYODIDE_BUILD)])
else:
Tools.run(["uv", "pip", "install", "pyodide-build"])
print("Building with pyodide...")
# Use --no-isolation due to pyodide-build Windows support issues:
# symlink_unisolated_packages fails with missing `_sysconfigdata_$(CPYTHON_ABI_FLAGS)_emscripten_wasm32-emscripten.py`.
# Hardcode platform name since pyodide doesn't yet support overriding wheel tags on Windows.
#
# Use `LEGACY_PLATFORM` since pyodide 0.34.1 introduced new tag for wheels `pyemscripten`,
# which doesn't work with pyodide itself yet - https://github.com/pyodide/pyodide/issues/6177.
os.environ["USE_LEGACY_PLATFORM"] = "1"
Tools.run(["pyodide", "build", f"-C--build-option=--plat-name={WHEEL_PLATFORM_TAG}"])
elapsed = time.time() - start_time
print(f"\n✓ Done! ({elapsed:.1f}s)")
if __name__ == "__main__":
main()
+39 -1
View File
@@ -2,12 +2,16 @@
# because `tool.setuptools.ext-modules` is still experimental in pyproject.toml
# and we need it to get the wheel suffix right.
import os
import sys
from pathlib import Path
import tomllib
from setuptools import Extension, find_packages, setup
from setuptools.command.build_ext import build_ext
REPO_FOLDER = Path(__file__).parent
# Detect repo folder: if setup.py is in pyodide folder, go to parent
SETUP_DIR = Path(__file__).parent
REPO_FOLDER = SETUP_DIR.parent if SETUP_DIR.name == "pyodide" else SETUP_DIR
def get_version() -> str:
@@ -25,6 +29,39 @@ def get_dependencies() -> list[str]:
return dependencies
class UnixBuildExt(build_ext):
"""Customize ``build_ext`` to support packing on Windows."""
def finalize_options(self):
from distutils import sysconfig
super().finalize_options()
if sys.platform == "win32":
self.compiler = "unix"
# Configure sysconfig for Windows builds
# CCSHARED is the only variable that's not customizable with env vars.
# Basically avoiding this:
# File ".venv\Lib\site-packages\setuptools\_distutils\sysconfig.py", line 366, in customize_compiler
# compiler_so=cc_cmd + ' ' + ccshared,
# ~~~~~~~~~~~~~^~~~~~~~~~
# TypeError: can only concatenate str (not "NoneType") to str
sysconfig.get_config_vars() # Initialize config cache
if sysconfig._config_vars.get("CCSHARED") is None:
sysconfig._config_vars["CCSHARED"] = "-fPIC"
# Override compiler type before it's instantiated
# Set Emscripten compiler environment variables
os.environ["CC"] = "emcc"
os.environ["CXX"] = "em++"
os.environ["CFLAGS"] = ""
os.environ["CXXFLAGS"] = ""
os.environ["LDSHARED"] = "emcc -shared"
os.environ["AR"] = "emar"
os.environ["ARFLAGS"] = "rcs"
os.environ["SETUPTOOLS_EXT_SUFFIX"] = ".cpython-313-wasm32-emscripten.so"
setup(
name="ifcopenshell",
version=get_version(),
@@ -44,4 +81,5 @@ setup(
},
# Has to provide extension to get the correct wheel suffix.
ext_modules=[Extension("ifcopenshell._ifcopenshell_wrapper", sources=[])],
cmdclass={"build_ext": UnixBuildExt},
)
+6 -4
View File
@@ -3,8 +3,9 @@ name = "IfcOpenShell"
version = "0.0.0"
dependencies = [
"black==26.3.1",
"ruff==0.15.8",
"ruff==0.15.9",
"poethepoet",
"ty==0.0.29",
"gersemi==0.26.1",
]
@@ -28,6 +29,9 @@ extend-exclude = '''
reportInvalidTypeForm = false
disableBytesTypePromotions = true
reportUnnecessaryTypeIgnoreComment = true
reportRedeclaration = false
# Ignore warnings from bpy stubs missing actual source files.
reportMissingModuleSource = false
# Pylance doesn't respect gitignore, so we have to exclude files manually here
# to avoid VS Code slowing down.
# https://github.com/microsoft/pylance-release/issues/5169
@@ -84,7 +88,6 @@ all = "ignore"
# Structural rules (no deep type inference needed, easier to adapt).
abstract-method-in-final-class = "error"
ambiguous-protocol-member = "error"
byte-string-type-annotation = "error"
conflicting-declarations = "error"
conflicting-metaclass = "error"
cyclic-class-definition = "error"
@@ -96,7 +99,6 @@ empty-body = "error"
escape-character-in-forward-annotation = "error"
final-on-non-method = "error"
final-without-value = "error"
fstring-type-annotation = "error"
ignore-comment-unknown-rule = "error"
implicit-concatenated-string-type-annotation = "error"
inconsistent-mro = "error"
@@ -224,7 +226,7 @@ ty.sequence = ["ty-bonsai", "ty-ios"]
ty.help = "Run ty type checker. Requires ty-venv to be set up first."
ty-bonsai = "ty check src/bonsai --python=src/bonsai/.venv"
ty-venv.sequence = ["ty-venv-bonsai", "ty-venv-ios"]
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"},
+1 -1
View File
@@ -64,7 +64,7 @@ PYNUMBER:=3$(PYMINOR)
PYPI_VERSION:=3.$(PYMINOR)
endif # def PYVERSION
IFCMERGE_VERSION:=2026-04-02
IFCMERGE_VERSION:=2026-04-07
ifdef PLATFORM
SUPPORTED_PLATFORMS := linux macos macosm1 win
@@ -101,7 +101,7 @@ class AggregateDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -295,13 +295,13 @@ class ExplorerShowUIPopup(bpy.types.Operator):
bl_description = "Show Explorer UI to select element as attribute value or edit it."
bl_options = {"REGISTER", "UNDO"}
ifc_class: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
ifc_class: bpy.props.StringProperty()
"""Element IFC class."""
attribute_name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
attribute_name: bpy.props.StringProperty()
"""IFC class attribute name."""
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path"""
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
preselect_ifc_id: bpy.props.IntProperty(options={"SKIP_SAVE"})
"""IFC id to preselect in the popup."""
if TYPE_CHECKING:
@@ -41,7 +41,7 @@ class BIMAttributeProperties(PropertyGroup):
class ExplorerEntity(PropertyGroup):
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
ifc_definition_id: bpy.props.IntProperty()
if TYPE_CHECKING:
ifc_definition_id: int
@@ -60,7 +60,7 @@ class BIMExplorerProperties(PropertyGroup):
self.property_unset("editing_entity_id")
self.entity_attributes.clear()
is_loaded: BoolProperty( # pyright: ignore[reportRedeclaration]
is_loaded: BoolProperty(
name="Toggle Explorer UI",
update=update_is_loaded,
)
@@ -76,15 +76,15 @@ class BIMExplorerProperties(PropertyGroup):
def update_ifc_class(self, context: object) -> None:
tool.Attribute.refresh_uilist_entities()
ifc_class: EnumProperty( # pyright: ignore[reportRedeclaration]
ifc_class: EnumProperty(
name="IFC Class To Search",
items=get_ifc_class,
update=update_ifc_class,
)
entities: CollectionProperty(type=ExplorerEntity) # pyright: ignore[reportRedeclaration]
active_entity_index: IntProperty() # pyright: ignore[reportRedeclaration]
editing_entity_id: IntProperty() # pyright: ignore[reportRedeclaration]
entity_attributes: CollectionProperty(type=Attribute) # pyright: ignore[reportRedeclaration]
entities: CollectionProperty(type=ExplorerEntity)
active_entity_index: IntProperty()
editing_entity_id: IntProperty()
entity_attributes: CollectionProperty(type=Attribute)
if TYPE_CHECKING:
is_loaded: bool
+4 -10
View File
@@ -201,16 +201,10 @@ class ExecuteIfcClash(bpy.types.Operator, ExportHelper):
"ALT+click to run a quick clash without selecting a file to save."
)
filter_glob: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
default="*.bcf;*.json", options={"HIDDEN"}
)
format: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name="Format", items=[(i, i, "") for i in ("bcf", "json")]
)
filepath: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
subtype="FILE_PATH", options={"SKIP_SAVE"}
)
quick_clash: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default="*.bcf;*.json", options={"HIDDEN"})
format: bpy.props.EnumProperty(name="Format", items=[(i, i, "") for i in ("bcf", "json")])
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
quick_clash: bpy.props.BoolProperty(
options={"SKIP_SAVE"},
)
+4 -4
View File
@@ -37,12 +37,12 @@ from bonsai.bim.prop import BIMFilterGroup, StrProperty
class ClashSource(PropertyGroup):
name: StringProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(
name="File",
description="Absolute filepath to existing .ifc file to use as a clash source.",
)
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups") # pyright: ignore[reportRedeclaration]
mode: EnumProperty( # pyright: ignore[reportRedeclaration]
filter_groups: CollectionProperty(type=BIMFilterGroup, name="Filter Groups")
mode: EnumProperty(
items=[
("a", "All Elements", "All elements will be used for clashing"),
("i", "Include", "Only the selected elements are included for clashing"),
@@ -62,7 +62,7 @@ class Clash(PropertyGroup):
b_global_id: StringProperty(name="B")
a_name: StringProperty(name="A Name")
b_name: StringProperty(name="B Name")
clash_type: EnumProperty( # pyright: ignore[reportRedeclaration]
clash_type: EnumProperty(
name="Clash Type",
items=tuple((i, i, "") for i in CLASH_TYPE_ITEMS),
)
@@ -87,7 +87,7 @@ class CopyCostSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Cost Schedule"
bl_description = "Create a duplicate of the provided cost schedule."
bl_options = {"REGISTER", "UNDO"}
cost_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
cost_schedule: bpy.props.IntProperty()
if TYPE_CHECKING:
cost_schedule: int
@@ -260,14 +260,14 @@ class CreateAllShapes(bpy.types.Operator):
)
bl_options = {"REGISTER"}
geometry_library: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
geometry_library: bpy.props.EnumProperty(
name="Geometry Library",
description="Geometry library to use for testing shape creation.",
items=[(i, i, "") for i in get_args(ifcopenshell.geom.GEOMETRY_LIBRARY)],
# By default use the same library as used for importing ifc project.
default="hybrid-cgal-simple-opencascade",
)
custom_geometry_library: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
custom_geometry_library: bpy.props.StringProperty(
name="Custom Geometry Library",
description="Provide a custom geometry library name, will override the 'geometry library' property.",
)
@@ -781,7 +781,7 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Purge Unused Objects"
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
@@ -827,7 +827,7 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator):
)
bl_options = {"REGISTER", "UNDO"}
object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
object_type: bpy.props.EnumProperty(
name="Object Type",
items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)),
)
@@ -1073,7 +1073,7 @@ class ChangeLogLevel(bpy.types.Operator):
bl_options = {"REGISTER"}
bl_description = "Change general log level across all Python code in Blender"
log_level: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
log_level: bpy.props.EnumProperty(
name="Log Level",
items=[(i, i, "") for i in get_args(LogLevelType)],
default="WARNING",
@@ -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( # pyright: ignore[reportRedeclaration]
print_all: bpy.props.BoolProperty(
name="Print All",
default=False,
options={"SKIP_SAVE"},
)
open_viewer: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
open_viewer: bpy.props.BoolProperty(
name="Open in Viewer",
default=False,
options={"SKIP_SAVE"},
)
sync: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
sync: bpy.props.BoolProperty(
name="Sync Before Creating Drawing",
description="Could save some time if you're sure IFC and current Blender session are already in sync",
default=True,
@@ -2322,14 +2322,14 @@ class ActivateDrawingBase(tool.Ifc.Operator):
+ "SHIFT+CLICK to load a quick preview of the drawing view"
)
drawing: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
should_view_from_camera: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
drawing: bpy.props.IntProperty()
should_view_from_camera: bpy.props.BoolProperty(
name="Should View From Camera",
description="Move view to the activated drawing's camera position.",
default=True,
options={"SKIP_SAVE"},
)
use_quick_preview: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_quick_preview: bpy.props.BoolProperty(
name="Use Quick Preview",
description="Just move the camera to the drawing view, without loading anything else.",
default=False,
@@ -3635,14 +3635,12 @@ class ToggleTargetView(bpy.types.Operator):
bl_label = "Toggle Target View"
bl_options = {"REGISTER", "UNDO"}
target_view: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
toggle_all: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
target_view: bpy.props.StringProperty()
toggle_all: bpy.props.BoolProperty(
default=False,
options={"SKIP_SAVE"},
)
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(ToggleOption)]
)
option: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(ToggleOption)])
if TYPE_CHECKING:
target_view: str
+2 -2
View File
@@ -860,13 +860,13 @@ class BIMTextProperties(PropertyGroup):
is_editing: BoolProperty(name="Is Editing", default=False)
literals: CollectionProperty(name="Literals", type=LiteralProps)
newline_at: IntProperty(name="Newline At")
symbol: EnumProperty( # pyright: ignore[reportRedeclaration]
symbol: EnumProperty(
name="Symbol",
description="Symbol from symbols.svg to use for this text.",
items=[(s, s, "") for s in ["NO SYMBOL", "CUSTOM SYMBOL"] + tool.Drawing.DEFAULT_SYMBOLS],
default="NO SYMBOL",
)
custom_symbol: StringProperty( # pyright: ignore[reportRedeclaration]
custom_symbol: StringProperty(
name="Custom Symbol",
description="Non-default symbol to use for this text.",
)
@@ -85,7 +85,7 @@ class EditObjectPlacement(bpy.types.Operator, tool.Ifc.Operator):
class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_mesh_separate"
bl_label = "IFC Mesh Separate"
blender_op = bpy.ops.mesh.separate.get_rna_type()
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
bl_description = blender_op.description + ".\nAlso makes sure changes are in sync with IFC."
bl_options = {"REGISTER", "UNDO"}
blender_type_prop = blender_op.properties["type"]
@@ -246,7 +246,7 @@ class OverrideMeshSeparate(bpy.types.Operator, tool.Ifc.Operator):
class OverrideOriginSet(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_origin_set"
blender_op = bpy.ops.object.origin_set.get_rna_type()
blender_op = bpy.ops.object.origin_set.get_rna_type() # ty: ignore[missing-argument]
bl_label = "IFC Origin Set"
bl_description = (
blender_op.description + ".\nAlso makes sure changes are in sync with IFC (operator works only on IFC objects)"
@@ -801,7 +801,7 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context
class OverrideDelete(bpy.types.Operator):
bl_idname = "bim.override_object_delete"
bl_label = "IFC Delete"
blender_op = bpy.ops.object.delete.get_rna_type()
blender_op = bpy.ops.object.delete.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes in sync with IFC."
@@ -821,7 +821,7 @@ class OverrideDelete(bpy.types.Operator):
def poll(cls, context):
# Match `object.delete` poll for consistency.
# `object.delete` poll just checks for OBJECT mode.
poll = bpy.ops.object.delete.poll()
poll = bpy.ops.object.delete.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
@@ -1045,7 +1045,7 @@ class SelectedIdsData(NamedTuple):
class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_outliner_delete"
bl_label = "IFC Delete"
blender_op = bpy.ops.outliner.delete.get_rna_type()
blender_op = bpy.ops.outliner.delete.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes in sync with IFC."
@@ -1060,7 +1060,7 @@ class OverrideOutlinerDelete(bpy.types.Operator, tool.Ifc.Operator):
def poll(cls, context) -> bool:
# Match `outliner.delete` poll for consistency.
# `outliner.delete` just checks `area.type` == `OUTLINER`.
poll = bpy.ops.outliner.delete.poll()
poll = bpy.ops.outliner.delete.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available from Outliner.")
@@ -1164,7 +1164,7 @@ class OverrideDuplicateMove(bpy.types.Operator):
def poll(cls, context) -> bool:
# Match `object.duplicate_move` poll for consistency.
# `object.duplicate_move` poll checks for OBJECT mode.
poll = bpy.ops.object.duplicate_move.poll()
poll = bpy.ops.object.duplicate_move.poll() # ty: ignore[missing-argument]
if poll:
return True
cls.poll_message_set("Only available in OBJECT mode")
@@ -1908,7 +1908,7 @@ class RefreshLinkedAggregate(bpy.types.Operator, tool.Ifc.Operator):
class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.override_object_join"
bl_label = "IFC Join"
blender_op = bpy.ops.mesh.separate.get_rna_type()
blender_op = bpy.ops.mesh.separate.get_rna_type() # ty: ignore[missing-argument]
bl_description = (
blender_op.description
+ ".\nAlso makes sure changes are in sync with IFC."
@@ -1926,7 +1926,7 @@ class OverrideJoin(bpy.types.Operator, tool.Ifc.Operator):
@classmethod
def poll(cls, context):
if not bpy.ops.object.join.poll():
if not bpy.ops.object.join.poll(): # ty: ignore[missing-argument]
cls.poll_message_set("Active object is not EDITable.")
return False
if not context.selected_editable_objects:
@@ -43,11 +43,11 @@ class ToggleGroup(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Toggle Group"
bl_options = {"REGISTER", "UNDO"}
ifc_definition_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
group_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
ifc_definition_id: bpy.props.IntProperty()
group_type: bpy.props.EnumProperty(
items=[(i, i, "") for i in get_args(tool.Group.GroupType)],
)
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(i, i, "") for i in get_args(tool.Group.ToggleOption)],
)
@@ -314,7 +314,7 @@ class SelectConflictEntity(bpy.types.Operator):
bl_idname = "ifcgit.select_conflict_entity"
bl_options = {"REGISTER"}
step_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
step_id: bpy.props.IntProperty()
if TYPE_CHECKING:
step_id: int
@@ -515,7 +515,7 @@ class RunGitDiff(bpy.types.Operator):
)
bl_options = set()
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
save_to_temp: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
save_to_temp: bool
@@ -547,7 +547,7 @@ class RenameBranch(bpy.types.Operator):
bl_idname = "ifcgit.rename_branch"
bl_options = {"REGISTER"}
new_name: bpy.props.StringProperty(name="New name") # pyright: ignore[reportRedeclaration]
new_name: bpy.props.StringProperty(name="New name")
if TYPE_CHECKING:
new_name: str
+29 -17
View File
@@ -23,7 +23,7 @@ from pathlib import Path
import bpy
import pyradiance
from . import list, operator, prop, ui
from . import export, ies, list, material, prepare, prop, render, solar, ui
def get_pyradiance_path():
@@ -31,31 +31,43 @@ def get_pyradiance_path():
classes = (
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,
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,
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.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMRadianceExporterProperties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties)
bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties)
if pyradiance:
@@ -68,5 +80,5 @@ def register():
def unregister():
del bpy.types.Scene.BIMRadianceExporeterProperies
del bpy.types.Scene.BIMRadianceExporterProperties
del bpy.types.Scene.BIMSolarProperties
@@ -0,0 +1,488 @@
# 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
@@ -0,0 +1,80 @@
# 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"}
+40 -5
View File
@@ -18,21 +18,18 @@
from __future__ import annotations
import json
import os
from pathlib import Path
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(
@@ -64,3 +61,41 @@ 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
@@ -0,0 +1,136 @@
# 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"}
+41 -713
View File
@@ -16,716 +16,44 @@
# 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 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
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
import bonsai.tool as tool
from bonsai.bim.module.light.data import SolarData
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"}
"""Thin re-export module for backward compatibility.
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
EnumPropertySearch and SetEnumProperty are provided by the global
bonsai.bim.operator module (bl_idname "bim.enum_property_search").
"""
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,
)
@@ -0,0 +1,741 @@
# 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
+312 -29
View File
@@ -16,6 +16,7 @@
# 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
@@ -43,7 +44,6 @@ 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 = sun_props.longitude
sun_props.latitude = sun_props.latitude
sun_props.longitude = self.longitude
sun_props.latitude = self.latitude
self["coordinates"] = sun_props.coordinates
update_sun_path(self)
@@ -135,6 +135,14 @@ 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()
@@ -152,9 +160,13 @@ 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 = self.day
sun_props.day = day
sun_props.time = self.hour + (self.minute / 60)
# Preserve IFC sign convention
sun_props.north_offset = self.true_north * -1
@@ -181,8 +193,11 @@ 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
# sun_vector.z = max(0, sun_vector.z)
# Light direction is a bit weird?
# 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
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()
@@ -193,6 +208,9 @@ 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:
@@ -223,6 +241,106 @@ 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:
@@ -233,6 +351,9 @@ 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
@@ -247,7 +368,10 @@ class RadianceExporterProperties(PropertyGroup):
mappings = json.load(f)
for style_id, mapping in mappings.items():
material = self.get_material_mapping(mapping["name"])
# 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"])
if material:
material.style_id = style_id
material.category = mapping["category"]
@@ -259,11 +383,14 @@ 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(style_name)
item = self.get_material_mapping_by_id(style_id) or self.get_material_mapping(style_name)
if item:
item.category = category
item.subcategory = subcategory
@@ -279,8 +406,12 @@ class RadianceExporterProperties(PropertyGroup):
if item.category and item.subcategory
}
def unmap_material(self, style_name: str) -> None:
item = self.get_material_mapping(style_name)
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)
if item:
item.category = ""
item.subcategory = ""
@@ -290,6 +421,14 @@ 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", ""),
@@ -316,16 +455,13 @@ class RadianceExporterProperties(PropertyGroup):
print(f"Material '{active_material.name}' mapped to {self.category} - {self.subcategory}")
category: bpy.props.EnumProperty(
items=categories, name="Category", description="Material category", update=update_material_mapping
items=get_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 # ty: ignore[unresolved-global]
if self.category in spectraldb:
SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()]
else:
SUBCATEGORIES_ENUM_ITEMS = []
return SUBCATEGORIES_ENUM_ITEMS
return sorted([(k, k, "") for k in spectraldb[self.category].keys()])
return []
subcategory: bpy.props.EnumProperty(
items=get_subcategories, name="Subcategory", description="Material subcategory", update=update_material_mapping
@@ -334,12 +470,9 @@ 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=False,
default=True,
)
radiance_resolution_x: IntProperty(
@@ -348,6 +481,13 @@ 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",
@@ -363,6 +503,15 @@ 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",
@@ -393,14 +542,15 @@ class RadianceExporterProperties(PropertyGroup):
name="Output File Format",
description="Format of the output image file",
items=[
("HDR", "HDR + Tiff", "High Dynamic Range"),
("HDR", "HDR", "High Dynamic Range (HDR) file only"),
("HDR_TIFF", "HDR + Tiff", "High Dynamic Range (HDR) and Tiff files"),
],
default="HDR",
default="HDR_TIFF",
)
use_hdr: BoolProperty(
name="Use HDR",
description="Use HDR image format",
name="HDR Environment Map",
description="Use an HDR image as the sky dome for realistic environment lighting and reflections",
default=True,
)
@@ -413,6 +563,40 @@ 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
)
@@ -424,8 +608,90 @@ 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]
@@ -433,8 +699,10 @@ 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"]
@@ -442,8 +710,22 @@ 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):
@@ -470,11 +752,12 @@ 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)
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)
# 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)
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)
@@ -0,0 +1,328 @@
# 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"}
@@ -0,0 +1,33 @@
# 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
@@ -0,0 +1,148 @@
# 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"}
+235 -43
View File
@@ -22,52 +22,97 @@ 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()
layout.label(text="Info: Unmapped materials default to white")
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")
row = layout.row()
row.template_list("MATERIAL_UL_radiance_materials", "", props, "materials", props, "active_material_index")
row.operator("radiance.open_spectraldb", text="", icon="WORLD") # Globe icon
row.operator("radiance.open_spectraldb", text="", icon="WORLD")
if len(props.materials) > 0:
col = layout.column(align=True)
col.prop(props, "category")
prop_with_search(col, props, "category")
if props.category:
col.prop(props, "subcategory")
prop_with_search(col, props, "subcategory")
if props.active_material_index >= 0 and props.active_material_index < len(props.materials):
if 0 <= 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} to {active_material.category} - {active_material.subcategory}"
text=f"Mapped: {active_material.name} -> {active_material.category} - {active_material.subcategory}"
)
else:
layout.label(text=f"Select category and subcategory for: {active_material.name}")
@@ -78,28 +123,95 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.operator("bim.refresh_ifc_materials", text="Refresh IFC Materials")
layout.separator()
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")
# ---------------------------------------------------------------------------
# 3. Lighting (Environment + IES)
# ---------------------------------------------------------------------------
layout.separator()
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="Camera Settings")
box.label(text="Environment", icon="WORLD")
row = box.row()
row.prop(props, "use_active_camera")
if not props.use_active_camera:
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, "selected_camera")
row.operator("radiance.select_camera", text="", icon="EYEDROPPER")
row.prop(props, "ground_reflectance")
row = box.row()
row.prop(props, "turbidity")
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")
layout.separator()
# IES Light Fixtures
box = layout.box()
box.label(text="IES Light Fixtures", icon="LIGHT_POINT")
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")
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 = layout.row()
row.prop(props, "radiance_quality")
@@ -110,6 +222,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = layout.row()
row.prop(props, "radiance_variability")
row = layout.row()
row.prop(props, "ambient_bounces")
layout.separator()
row = layout.row()
@@ -117,20 +232,94 @@ class BIM_PT_radiance_exporter(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.prop(props, "use_hdr")
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
if props.use_hdr:
row = layout.row()
row.prop(props, "choose_hdr_image")
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
# ---------------------------------------------------------------------------
# Solar Panel (unchanged)
# ---------------------------------------------------------------------------
class BIM_PT_solar(bpy.types.Panel):
@@ -248,7 +437,10 @@ class BIM_PT_solar(bpy.types.Panel):
row = self.layout.row()
sun_props = tool.Blender.get_sun_props()
assert sun_props
row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity")
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 = self.layout.row(align=True)
row.operator("bim.view_from_sun", icon="LIGHT_HEMI")
@@ -136,7 +136,7 @@ class SplitAlongEdge(bpy.types.Operator, tool.Ifc.Operator):
"Will unassign element from a type if type has a representation."
)
bl_options = {"REGISTER", "UNDO"}
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty(
default="BOOLEAN",
items=tuple((i, i, "") for i in get_args(SplitAlongEdgeMode)),
)
@@ -359,7 +359,7 @@ class ConfirmQuickFavoriteOperator(bpy.types.Operator):
bl_idname = "bim.confirm_quick_favorite_operator"
bl_label = "Confirm Operator"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
@@ -452,10 +452,8 @@ class MoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.move_quick_favorites_item"
bl_label = "Move Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
direction: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[("UP", "Up", ""), ("DOWN", "Down", "")]
)
index: bpy.props.IntProperty()
direction: bpy.props.EnumProperty(items=[("UP", "Up", ""), ("DOWN", "Down", "")])
if TYPE_CHECKING:
index: int
@@ -474,7 +472,7 @@ class RemoveQuickFavoritesItem(bpy.types.Operator):
bl_idname = "bim.remove_quick_favorites_item"
bl_label = "Remove Quick Favorites Item"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
index: int
+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="") # pyright: ignore[reportRedeclaration]
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
description: StringProperty(name="Description", default="") # pyright: ignore[reportRedeclaration]
name: StringProperty(name="Name", default="")
display_name: StringProperty(name="Display Name", default="")
description: StringProperty(name="Description", default="")
if TYPE_CHECKING:
name: str
@@ -51,19 +51,19 @@ def get_enum_items(self: "QuickFavoriteProperty", context: bpy.types.Context | N
class QuickFavoriteProperty(PropertyGroup):
name: StringProperty(name="Name", default="") # pyright: ignore[reportRedeclaration]
display_name: StringProperty(name="Display Name", default="") # pyright: ignore[reportRedeclaration]
value_prop: EnumProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(name="Name", default="")
display_name: StringProperty(name="Display Name", default="")
value_prop: EnumProperty(
name="Value Prop",
items=tuple((v, v, "") for v in get_args(QuickFavoriteValueType)),
)
string_value: StringProperty(name="String Value", default="") # pyright: ignore[reportRedeclaration]
float_value: FloatProperty(name="Float Value", default=0.0) # pyright: ignore[reportRedeclaration]
int_value: IntProperty(name="Int Value", default=0) # pyright: ignore[reportRedeclaration]
bool_value: BoolProperty(name="Bool Value", default=False) # pyright: ignore[reportRedeclaration]
enum_value: EnumProperty(name="Enum Value", items=get_enum_items) # pyright: ignore[reportRedeclaration]
enum_items: CollectionProperty(type=QuickFavoriteEnumItem) # pyright: ignore[reportRedeclaration]
is_active: BoolProperty( # pyright: ignore[reportRedeclaration]
string_value: StringProperty(name="String Value", default="")
float_value: FloatProperty(name="Float Value", default=0.0)
int_value: IntProperty(name="Int Value", default=0)
bool_value: BoolProperty(name="Bool Value", default=False)
enum_value: EnumProperty(name="Enum Value", items=get_enum_items)
enum_items: CollectionProperty(type=QuickFavoriteEnumItem)
is_active: BoolProperty(
name="Is Active",
description="Only active properties will be added to the operator when invoked from Quick Favorites",
default=False,
@@ -100,20 +100,20 @@ def get_operator_suggestions(self: "QuickFavoritesItem", context: bpy.types.Cont
class QuickFavoritesItem(PropertyGroup):
is_expanded: BoolProperty(name="Is Expanded", default=False) # pyright: ignore[reportRedeclaration]
search: StringProperty( # pyright: ignore[reportRedeclaration]
is_expanded: BoolProperty(name="Is Expanded", default=False)
search: StringProperty(
name="Search",
default="",
search=get_operator_suggestions,
# Resetting `search_options`, allowing users only to use suggestions.
search_options=set(),
)
properties: CollectionProperty(type=QuickFavoriteProperty) # pyright: ignore[reportRedeclaration]
operator_id: StringProperty( # pyright: ignore[reportRedeclaration]
properties: CollectionProperty(type=QuickFavoriteProperty)
operator_id: StringProperty(
name="Operator ID",
default="",
)
label: StringProperty( # pyright: ignore[reportRedeclaration]
label: StringProperty(
name="Label",
description="Label that will be used in Quick Favorites for this operator",
default="",
@@ -139,15 +139,15 @@ class QuickFavoritesItem(PropertyGroup):
class BIMMiscProperties(PropertyGroup):
total_storeys: IntProperty( # pyright: ignore[reportRedeclaration]
total_storeys: IntProperty(
name="Total Storeys",
description="Number of storeys above object's storey to take into account for resizing",
default=1,
)
override_colour: FloatVectorProperty( # pyright: ignore[reportRedeclaration]
override_colour: FloatVectorProperty(
name="Override Colour", subtype="COLOR", default=(1, 0, 0, 1), min=0.0, max=1.0, size=4
)
quick_favorites: CollectionProperty(type=QuickFavoritesItem) # pyright: ignore[reportRedeclaration]
quick_favorites: CollectionProperty(type=QuickFavoritesItem)
if TYPE_CHECKING:
total_storeys: int
@@ -545,7 +545,7 @@ class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.change_type_page"
bl_label = "Change Type Page"
bl_options = {"REGISTER"}
page: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
page: bpy.props.IntProperty()
if TYPE_CHECKING:
page: int
@@ -271,7 +271,7 @@ class ExtendProfile(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.extend_profile"
bl_label = "Extend Profile"
bl_options = {"REGISTER", "UNDO"}
join_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
join_type: bpy.props.EnumProperty(
items=[("-", "Unjoin", ""), ("L", "L", ""), ("V", "V", ""), ("T", "T", "")],
default="-",
)
+4 -4
View File
@@ -1729,20 +1729,20 @@ def poll_sverchok_nodes(self: "BIMExternalParametricGeometryProperties", node_tr
class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
is_editing: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
is_editing: bpy.props.BoolProperty(
name="Is Editing Paramteric Geometry",
description="Toggle editing parametric geometry.",
default=False,
update=update_is_editing,
)
geometry_source: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
geometry_source: bpy.props.EnumProperty(
name="Geometry Source",
items=[
("GEONODES", "Geometry Nodes", ""),
("IFCSVERCHOK", "IFC Sverchok", ""),
],
)
geo_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
geo_nodes: bpy.props.PointerProperty(
name="Geometry Nodes",
description="Geometry nodes tree to use as a source for representation.",
type=bpy.types.GeometryNodeTree,
@@ -1750,7 +1750,7 @@ class BIMExternalParametricGeometryProperties(bpy.types.PropertyGroup):
poll=lambda self, node_tree: not node_tree.name.startswith("BBIM_EPG"),
)
sverchok_nodes: bpy.props.PointerProperty( # pyright: ignore[reportRedeclaration]
sverchok_nodes: bpy.props.PointerProperty(
name="Sverchok Nodes",
description="Sverchok node tree to use as a source for representation.",
type=bpy.types.NodeTree,
@@ -101,7 +101,7 @@ class NestDecorator:
cls.is_installed = False
def dotted_line_shader(self):
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("FLOAT", "v_ArcLength")
shader_info = gpu.types.GPUShaderCreateInfo()
+27 -27
View File
@@ -33,7 +33,7 @@ class EnableEditingPerson(bpy.types.Operator):
bl_idname = "bim.enable_editing_person"
bl_label = "Enable Editing Person"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -75,7 +75,7 @@ class RemovePerson(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_person"
bl_label = "Remove Person"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -88,7 +88,7 @@ class AddPersonAttribute(bpy.types.Operator):
bl_idname = "bim.add_person_attribute"
bl_label = "Add Person Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
)
@@ -104,10 +104,10 @@ class RemovePersonAttribute(bpy.types.Operator):
bl_idname = "bim.remove_person_attribute"
bl_label = "Remove Person Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.PersonAttributeType)),
)
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
id: bpy.props.IntProperty()
if TYPE_CHECKING:
name: tool.Owner.PersonAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
@@ -122,7 +122,7 @@ class EnableEditingRole(bpy.types.Operator):
bl_idname = "bim.enable_editing_role"
bl_label = "Enable Editing Role"
bl_options = {"REGISTER", "UNDO"}
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
role: bpy.props.IntProperty()
if TYPE_CHECKING:
role: int
@@ -146,7 +146,7 @@ class AddRole(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_role"
bl_label = "Add Role"
bl_options = {"REGISTER", "UNDO"}
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
parent: bpy.props.IntProperty()
if TYPE_CHECKING:
parent: int
@@ -168,7 +168,7 @@ class RemoveRole(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_role"
bl_label = "Remove Role"
bl_options = {"REGISTER", "UNDO"}
role: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
role: bpy.props.IntProperty()
if TYPE_CHECKING:
role: int
@@ -181,8 +181,8 @@ class AddAddress(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_address"
bl_label = "Add Address"
bl_options = {"REGISTER", "UNDO"}
parent: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
ifc_class: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
parent: bpy.props.IntProperty()
ifc_class: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(ADDRESS_TYPE)),
)
@@ -198,7 +198,7 @@ class AddAddressAttribute(bpy.types.Operator):
bl_idname = "bim.add_address_attribute"
bl_label = "Add Address Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
)
@@ -214,10 +214,10 @@ class RemoveAddressAttribute(bpy.types.Operator):
bl_idname = "bim.remove_address_attribute"
bl_label = "Remove Address Attribute"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
name: bpy.props.EnumProperty(
items=tuple((i, i, "") for i in get_args(tool.Owner.AddressAttributeType)),
)
id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
id: bpy.props.IntProperty()
if TYPE_CHECKING:
name: tool.Owner.AddressAttributeType # pyright: ignore[reportIncompatibleVariableOverride]
@@ -232,7 +232,7 @@ class EnableEditingAddress(bpy.types.Operator):
bl_idname = "bim.enable_editing_address"
bl_label = "Enable Editing Address"
bl_options = {"REGISTER", "UNDO"}
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
address: bpy.props.IntProperty()
if TYPE_CHECKING:
address: int
@@ -265,7 +265,7 @@ class RemoveAddress(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_address"
bl_label = "Remove Address"
bl_options = {"REGISTER", "UNDO"}
address: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
address: bpy.props.IntProperty()
if TYPE_CHECKING:
address: int
@@ -278,7 +278,7 @@ class EnableEditingOrganisation(bpy.types.Operator):
bl_idname = "bim.enable_editing_organisation"
bl_label = "Enable Editing Organisation"
bl_options = {"REGISTER", "UNDO"}
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
organisation: int
@@ -320,7 +320,7 @@ class RemoveOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_organisation"
bl_label = "Remove Organisation"
bl_options = {"REGISTER", "UNDO"}
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
organisation: int
@@ -333,8 +333,8 @@ class AddPersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.add_person_and_organisation"
bl_label = "Add Person And Organisation"
bl_options = {"REGISTER", "UNDO"}
person: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person: bpy.props.IntProperty()
organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
person: int
@@ -350,7 +350,7 @@ class RemovePersonAndOrganisation(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_person_and_organisation"
bl_label = "Remove Person And Organisation"
bl_options = {"REGISTER", "UNDO"}
person_and_organisation: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
person_and_organisation: bpy.props.IntProperty()
if TYPE_CHECKING:
person_and_organisation: int
@@ -365,7 +365,7 @@ class SetUser(bpy.types.Operator):
bl_idname = "bim.set_user"
bl_label = "Set User"
bl_options = {"REGISTER", "UNDO"}
user: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
user: bpy.props.IntProperty()
if TYPE_CHECKING:
user: int
@@ -401,7 +401,7 @@ class EnableEditingActor(bpy.types.Operator):
bl_idname = "bim.enable_editing_actor"
bl_label = "Enable Editing Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -434,7 +434,7 @@ class RemoveActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.remove_actor"
bl_label = "Remove Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -447,7 +447,7 @@ class AssignActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.assign_actor"
bl_label = "Assign Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -462,7 +462,7 @@ class UnassignActor(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.unassign_actor"
bl_label = "Unassign Actor"
bl_options = {"REGISTER", "UNDO"}
actor: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
actor: bpy.props.IntProperty()
if TYPE_CHECKING:
actor: int
@@ -481,7 +481,7 @@ class RemoveApplication(bpy.types.Operator, tool.Ifc.Operator):
"Remove provided IfcApplication."
"\n\nFor safety will only work on applications without inverses (they are typically marked as '(unused)'."
)
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
application_id: bpy.props.IntProperty()
if TYPE_CHECKING:
application_id: int
@@ -525,7 +525,7 @@ class EnableEditingApplication(bpy.types.Operator):
bl_idname = "bim.enable_editing_application"
bl_label = "Enable Editing Application"
bl_options = {"REGISTER", "UNDO"}
application_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
application_id: bpy.props.IntProperty()
if TYPE_CHECKING:
application_id: int
@@ -86,9 +86,7 @@ class NewProject(bpy.types.Operator):
bl_label = "New Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Start a new IFC project in a fresh session"
preset: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
items=[(i, i, "") for i in get_args(PresetType)]
)
preset: bpy.props.EnumProperty(items=[(i, i, "") for i in get_args(PresetType)])
if TYPE_CHECKING:
preset: PresetType
@@ -178,13 +176,9 @@ class SelectLibraryFile(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_description = (
"Select an IFC file that can be used as a library.\n\nALT+click to reload the current loaded library file."
)
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
append_all: bpy.props.BoolProperty(default=False) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"})
append_all: bpy.props.BoolProperty(default=False)
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -568,7 +562,7 @@ class AppendLibraryElementByQuery(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.append_library_element_by_query"
bl_label = "Append Library Element By Query"
query: bpy.props.StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty(name="Query")
if TYPE_CHECKING:
query: str
@@ -600,11 +594,9 @@ class AppendLibraryElement(bpy.types.Operator, tool.Ifc.Operator):
"Append element to the current project.\n\n"
"ALT+CLICK to skip reusing materials, profiles, styles based on their name (may result in duplicates)"
)
definition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
prop_index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
assume_unique_by_name: bpy.props.BoolProperty(
name="Assume Unique By Name", default=True, options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
definition: bpy.props.IntProperty()
prop_index: bpy.props.IntProperty()
assume_unique_by_name: bpy.props.BoolProperty(name="Assume Unique By Name", default=True, options={"SKIP_SAVE"})
if TYPE_CHECKING:
definition: int
@@ -959,28 +951,24 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
bl_label = "Load Project"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load an existing IFC project"
filepath: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE"}
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(
default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
is_advanced: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
filepath: bpy.props.StringProperty(subtype="FILE_PATH", options={"SKIP_SAVE"})
filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml;*.ifcsqlite", options={"HIDDEN"})
is_advanced: bpy.props.BoolProperty(
name="Enable Advanced Mode",
description="Load IFC file with advanced settings. Checking this option will skip loading IFC file and will open advanced load settings",
default=False,
)
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
description="Store the IFC project path relative to the .blend file. Requires .blend file to be saved",
default=False,
)
should_start_fresh_session: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
should_start_fresh_session: bpy.props.BoolProperty(
name="Should Start Fresh Session",
description="Clear current Blender session before loading IFC. Not supported with 'Use Relative Path' option",
default=True,
)
import_without_ifc_data: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
import_without_ifc_data: bpy.props.BoolProperty(
name="Import Without IFC Data",
description=(
"Import IFC objects as Blender objects without any IFC metadata and authoring capabilities."
@@ -988,9 +976,7 @@ class LoadProject(bpy.types.Operator, IFCFileSelector, ImportHelper):
),
default=False,
)
use_detailed_tooltip: bpy.props.BoolProperty(
default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_detailed_tooltip: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
filename_ext = ".ifc"
if TYPE_CHECKING:
@@ -1300,7 +1286,7 @@ class ToggleFilterCategories(bpy.types.Operator):
bl_idname = "bim.toggle_filter_categories"
bl_label = "Toggle Filter Categories"
bl_options = {"REGISTER", "UNDO"}
should_select: bpy.props.BoolProperty(name="Should Select", default=True) # pyright: ignore[reportRedeclaration]
should_select: bpy.props.BoolProperty(name="Should Select", default=True)
if TYPE_CHECKING:
should_select: bool
@@ -1327,7 +1313,7 @@ class LinkIfc(bpy.types.Operator, ImportHelper, tool.Ifc.Operator):
default=False,
)
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty( # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty(
name="Query",
description=(
"Custom selector query to use to load element from a linked model. E.g. 'IfcElement'.\n\n"
@@ -1404,7 +1390,7 @@ class UnlinkIfc(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Remove the selected file from the link list"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1428,7 +1414,7 @@ class UnloadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Unload the selected linked file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1454,9 +1440,9 @@ class LoadLink(bpy.types.Operator, tool.Ifc.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Load the selected file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True) # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
use_cache: bpy.props.BoolProperty(name="Use Cache", default=True)
query: bpy.props.StringProperty()
if TYPE_CHECKING:
link_index: int
@@ -1631,7 +1617,7 @@ class ReloadLink(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Reload the selected file"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1647,7 +1633,7 @@ class ToggleLinkSelectability(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle selectability"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1679,8 +1665,8 @@ class ToggleLinkVisibility(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Toggle visibility between SOLID and WIREFRAME"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
mode: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
mode: bpy.props.EnumProperty(
name="Visibility Mode",
items=((i, i, "") for i in ("WIREFRAME", "VISIBLE")),
)
@@ -1821,7 +1807,7 @@ class SelectLinkHandle(bpy.types.Operator):
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select link empty object handle"
link_index: bpy.props.IntProperty(name="Link Index") # pyright: ignore[reportRedeclaration]
link_index: bpy.props.IntProperty(name="Link Index")
if TYPE_CHECKING:
link_index: int
@@ -1843,7 +1829,7 @@ class SelectLinkedModelElement(bpy.types.Operator):
bl_options = {"REGISTER"}
bl_description = "Select an element in the currently selected linked model by providing GlobalId."
guid: bpy.props.StringProperty(name="GlobalId") # pyright: ignore[reportRedeclaration]
guid: bpy.props.StringProperty(name="GlobalId")
if TYPE_CHECKING:
guid: str
@@ -1882,21 +1868,11 @@ class ExportIFC(bpy.types.Operator, ExportHelper):
bl_options = {"REGISTER", "UNDO"}
filename_ext = ".ifc"
supported_filexts = (".ifc", ".ifczip", ".ifcjson")
filter_glob: bpy.props.StringProperty(
default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
json_version: bpy.props.EnumProperty(
items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version"
) # pyright: ignore[reportRedeclaration]
json_compact: bpy.props.BoolProperty(
name="Export Compact IFCJSON", default=False
) # pyright: ignore[reportRedeclaration]
should_save_as: bpy.props.BoolProperty(
name="Should Save As", default=False, options={"HIDDEN"}
) # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path", default=False
) # pyright: ignore[reportRedeclaration]
filter_glob: bpy.props.StringProperty(default=";".join(f"*{ext}" for ext in supported_filexts), options={"HIDDEN"})
json_version: bpy.props.EnumProperty(items=[("4", "4", ""), ("5a", "5a", "")], name="IFC JSON Version")
json_compact: bpy.props.BoolProperty(name="Export Compact IFCJSON", default=False)
should_save_as: bpy.props.BoolProperty(name="Should Save As", default=False, options={"HIDDEN"})
use_relative_path: bpy.props.BoolProperty(name="Use Relative Path", default=False)
if TYPE_CHECKING:
filter_glob: str
@@ -2053,7 +2029,7 @@ class LoadLinkedProject(bpy.types.Operator, ImportHelper):
bl_description = "Operator is used to load a project .cache.blend to then link it to the IFC file."
bl_options = {"REGISTER", "UNDO"}
query: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
query: bpy.props.StringProperty()
"""See ``bim.link_ifc``."""
if TYPE_CHECKING:
@@ -2443,8 +2419,8 @@ class HideQueriedLinkedElement(bpy.types.Operator):
)
bl_options = {"REGISTER", "UNDO"}
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
unhide_all: bpy.props.BoolProperty(options={"SKIP_SAVE"})
hide_all_except: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
unhide_all: bool
@@ -2918,12 +2894,8 @@ class IFCFileHandlerOperator(bpy.types.Operator):
bl_label = "Import .ifc file"
bl_options = {"REGISTER", "UNDO", "INTERNAL"}
directory: bpy.props.StringProperty(
subtype="FILE_PATH", options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
files: bpy.props.CollectionProperty(
type=bpy.types.OperatorFileListElement, options={"SKIP_SAVE", "HIDDEN"}
) # pyright: ignore[reportRedeclaration]
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
@@ -2978,7 +2950,7 @@ class MeasureTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@@ -3077,7 +3049,7 @@ class MeasureFaceAreaTool(bpy.types.Operator, PolylineOperator):
bl_label = "Measure Face Area Tool"
bl_options = {"REGISTER", "UNDO"}
measure_type: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
measure_type: bpy.props.StringProperty()
if TYPE_CHECKING:
measure_type: str
@@ -3379,7 +3351,7 @@ class LoadBlendMetadataAndIFC(bpy.types.Operator):
bl_idname = "bim.load_blend_metadata_and_ifc"
bl_label = "Load Blend Metadata and IFC"
bl_options = {"REGISTER", "UNDO"}
filepath: bpy.props.StringProperty(name="IFC File Path", default="") # pyright: ignore[reportRedeclaration]
filepath: bpy.props.StringProperty(name="IFC File Path", default="")
if TYPE_CHECKING:
filepath: str
+1 -1
View File
@@ -345,7 +345,7 @@ class BIMProjectProperties(PropertyGroup):
),
default=False,
)
should_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
should_cache: BoolProperty(
name="Cache",
description=(
"Cache loaded geometry to .h5 file in your cache directory (see in preferences) "
@@ -240,7 +240,7 @@ class CopyPropertyToSelection(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Property To Selection"
bl_options = {"REGISTER", "UNDO"}
name: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
name: bpy.props.StringProperty()
if TYPE_CHECKING:
name: str
@@ -280,10 +280,10 @@ class BIM_OT_add_property_to_edit(bpy.types.Operator):
bl_label = "Add Property to Edit"
bl_idname = "bim.add_property_to_edit"
bl_options = {"REGISTER", "UNDO"}
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
index: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty(default=-1)
if TYPE_CHECKING:
option: tool.Pset.BulkOperationType
@@ -307,9 +307,9 @@ class BIM_OT_remove_property_to_edit(bpy.types.Operator):
bl_label = "Remove Property from Editing"
bl_idname = "bim.remove_property_to_edit"
bl_options = {"REGISTER", "UNDO"}
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index2: bpy.props.IntProperty(default=-1) # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
index2: bpy.props.IntProperty(default=-1)
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
@@ -336,7 +336,7 @@ class BIM_OT_bulk_edit_clear_list(bpy.types.Operator):
bl_label = "Clear List of Properties"
bl_idname = "bim.pset_bulk_edit_clear_list"
bl_options = {"REGISTER", "UNDO"}
option: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
option: bpy.props.EnumProperty(
items=[(t, t, "") for t in tool.Pset.BULK_OPERATION_TYPES],
)
+3 -3
View File
@@ -368,9 +368,9 @@ class GlobalPsetProperties(PropertyGroup):
qto_filter: StringProperty(name="Qto Filter", options={"TEXTEDIT_UPDATE"})
# Bulk operations.
psets_to_delete: CollectionProperty(type=DeletePsetEntry) # pyright: ignore[reportRedeclaration]
psets_to_rename: CollectionProperty(type=RenamePropertyEntry) # pyright: ignore[reportRedeclaration]
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry) # pyright: ignore[reportRedeclaration]
psets_to_delete: CollectionProperty(type=DeletePsetEntry)
psets_to_rename: CollectionProperty(type=RenamePropertyEntry)
psets_to_add_edit: CollectionProperty(type=AddEditPropertyEntry)
if TYPE_CHECKING:
pset_filter: str
@@ -799,7 +799,7 @@ class SelectQueryElements(Operator):
bl_description = "Select elements matching an provided selector query"
bl_options = {"REGISTER", "UNDO"}
query: StringProperty(name="Query") # pyright: ignore[reportRedeclaration]
query: StringProperty(name="Query")
if TYPE_CHECKING:
query: str
@@ -829,12 +829,12 @@ class SaveSearch(Operator, tool.Ifc.Operator):
# Extra item so it will be easy to select current text.
return [text] + SaveSearch.name_search_items
name: StringProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty(
name="Name",
search=get_name_search_items,
search_options={"SORT"},
)
module: StringProperty() # pyright: ignore[reportRedeclaration]
module: StringProperty()
def update_use_all_ifcgroups(self, context: object = None) -> None:
ifc_file = tool.Ifc.get()
@@ -845,7 +845,7 @@ class SaveSearch(Operator, tool.Ifc.Operator):
}
self.name_search_items[:] = natsorted(groups)
use_all_ifcgroups: BoolProperty( # pyright: ignore[reportRedeclaration]
use_all_ifcgroups: BoolProperty(
name="Use Any IfcGroup",
description=(
"By default we're targeting only IfcGroups with SEARCH ObjectType "
@@ -106,7 +106,7 @@ class ActivateStatusFilters(bpy.types.Operator):
bl_description = "Filter and display objects based on currently selected IFC statuses"
bl_options = {"REGISTER", "UNDO"}
only_if_enabled: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
only_if_enabled: bpy.props.BoolProperty(
name="Only If Filters are Enabled",
description="Activate status filters only in case if they were enabled from the UI before.",
default=False,
@@ -137,7 +137,7 @@ class SelectStatusFilter(bpy.types.Operator):
bl_description = "Select elements with currently selected status"
bl_options = {"REGISTER", "UNDO"}
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
status: bpy.props.StringProperty()
if TYPE_CHECKING:
status: tool.Sequence.ElementStatusUI
@@ -156,7 +156,7 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
bl_description = "Assign status to the selected elements.\n\nAlt+CLICK to unassign the status."
bl_options = {"REGISTER", "UNDO"}
should_override_previous_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
should_override_previous_status: bpy.props.BoolProperty(
name="Override Previous Status",
description=(
"Whether assigning new status should override previous one.\n\n"
@@ -165,8 +165,8 @@ class AssignStatus(bpy.types.Operator, tool.Ifc.Operator):
),
default=True,
)
status: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
should_unassign_status: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
status: bpy.props.StringProperty()
should_unassign_status: bpy.props.BoolProperty(
options={"SKIP_SAVE"},
)
@@ -415,7 +415,7 @@ class CopyWorkSchedule(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy Work Schedule"
bl_description = "Create a duplicate of the provided work schedule."
bl_options = {"REGISTER", "UNDO"}
work_schedule: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
work_schedule: bpy.props.IntProperty()
if TYPE_CHECKING:
work_schedule: int
@@ -412,7 +412,7 @@ WorkPlanEditingType = Literal["-", "ATTRIBUTES", "SCHEDULES", "WORK_SCHEDULE", "
class BIMWorkPlanProperties(PropertyGroup):
work_plan_attributes: CollectionProperty(name="Work Plan Attributes", type=Attribute)
editing_type: EnumProperty( # pyright: ignore[reportRedeclaration]
editing_type: EnumProperty(
items=[(i, i, "") for i in get_args(WorkPlanEditingType)],
)
work_plans: CollectionProperty(name="Work Plans", type=WorkPlan)
@@ -430,8 +430,8 @@ class BIMWorkPlanProperties(PropertyGroup):
class IFCStatus(PropertyGroup):
name: StringProperty() # pyright: ignore[reportRedeclaration]
is_visible: BoolProperty( # pyright: ignore[reportRedeclaration]
name: StringProperty()
is_visible: BoolProperty(
name="Is Visible", default=True, update=lambda x, y: (None, bpy.ops.bim.activate_status_filters())[0]
)
@@ -220,7 +220,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Copy to Container"
bl_options = {"REGISTER", "UNDO"}
container: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
container: bpy.props.IntProperty()
if TYPE_CHECKING:
container: int
@@ -167,7 +167,7 @@ class EnableEditingStructuralBoundaryCondition(bpy.types.Operator):
bl_idname = "bim.enable_editing_structural_boundary_condition"
bl_label = "Enable Editing Structural Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
boundary_condition: bpy.props.IntProperty()
if TYPE_CHECKING:
boundary_condition: int
@@ -186,7 +186,7 @@ class EditStructuralBoundaryCondition(bpy.types.Operator, tool.Ifc.Operator):
bl_idname = "bim.edit_structural_boundary_condition"
bl_label = "Edit Structural Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
connection: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
connection: bpy.props.IntProperty()
if TYPE_CHECKING:
connection: int
@@ -917,7 +917,7 @@ class EnableEditingBoundaryCondition(bpy.types.Operator):
bl_idname = "bim.enable_editing_boundary_condition"
bl_label = "Enable Editing Boundary Condition"
bl_options = {"REGISTER", "UNDO"}
boundary_condition: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
boundary_condition: bpy.props.IntProperty()
if TYPE_CHECKING:
boundary_condition: int
@@ -83,7 +83,7 @@ class DecorationShader:
PARALLEL DISTRIBUTED FORCE,
DISTRIBUTED MOMENT,
"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty:ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "forces")
vert_out.smooth("VEC3", "co")
@@ -203,7 +203,7 @@ class DecorationShader:
"""param: pattern: type of pattern
SINGLE FORCE,
SINGLE MOMENT"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -253,7 +253,7 @@ class DecorationShader:
def get_planar_shader(self) -> gpu.types.GPUShader:
"""shader for planar loads"""
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") # ty: ignore[too-many-positional-arguments]
vert_out = gpu.types.GPUStageInterfaceInfo("my_interface")
vert_out.smooth("VEC3", "co")
shader_info = gpu.types.GPUShaderCreateInfo()
@@ -54,7 +54,7 @@ class AddSystem(bpy.types.Operator, tool.Ifc.Operator):
bl_label = "Add System"
bl_options = {"REGISTER", "UNDO"}
parent_system_id: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
parent_system_id: bpy.props.IntProperty()
if TYPE_CHECKING:
parent_system_id: int
+3 -3
View File
@@ -26,16 +26,16 @@ from bpy.types import PropertyGroup
class WebProperties(PropertyGroup):
webserver_port: IntProperty( # pyright: ignore[reportRedeclaration]
webserver_port: IntProperty(
name="Webserver Port",
min=0,
max=65535,
)
is_running: BoolProperty( # pyright: ignore[reportRedeclaration]
is_running: BoolProperty(
name="Webserver Running Status",
default=False,
)
is_connected: BoolProperty( # pyright: ignore[reportRedeclaration]
is_connected: BoolProperty(
name="Connection Status",
default=False,
)
+6 -6
View File
@@ -159,9 +159,9 @@ class SelectURIAttribute(bpy.types.Operator, ImportHelper):
bl_label = "Select URI Attribute"
bl_options = {"REGISTER", "UNDO"}
bl_description = "Select a local file"
attribute_data_path: bpy.props.StringProperty(name="Data Path") # pyright: ignore[reportRedeclaration]
attribute_data_path: bpy.props.StringProperty(name="Data Path")
"""Full data path to `Attribute`/string property."""
use_relative_path: bpy.props.BoolProperty( # pyright: ignore[reportRedeclaration]
use_relative_path: bpy.props.BoolProperty(
name="Use Relative Path",
default=False,
)
@@ -601,7 +601,7 @@ class CreateMacBonsaiApp(bpy.types.Operator):
"ALT+click to uninstall Bonsai app if it was installed previously."
)
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"}) # pyright: ignore[reportRedeclaration]
uninstall: bpy.props.BoolProperty(options={"SKIP_SAVE"})
if TYPE_CHECKING:
uninstall: bool
@@ -1667,7 +1667,7 @@ class BIM_OT_attribute_add_subitem(bpy.types.Operator):
bl_description = "Add subitem to the current attribute"
bl_options = {"REGISTER", "UNDO"}
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path."""
if TYPE_CHECKING:
@@ -1691,9 +1691,9 @@ class BIM_OT_attribute_remove_subitem(bpy.types.Operator):
bl_description = "Add subitem to the current attribute"
bl_options = {"REGISTER", "UNDO"}
data_path: bpy.props.StringProperty() # pyright: ignore[reportRedeclaration]
data_path: bpy.props.StringProperty()
"""Full data path."""
index: bpy.props.IntProperty() # pyright: ignore[reportRedeclaration]
index: bpy.props.IntProperty()
if TYPE_CHECKING:
data_path: str
+2 -2
View File
@@ -333,7 +333,7 @@ class Attribute(PropertyGroup):
filter_glob: StringProperty()
is_null: BoolProperty(name="Is Null", update=update_is_null)
is_selected: BoolProperty(name="Is Selected", default=False)
subitems_values: CollectionProperty(type=StrProperty) # pyright: ignore[reportRedeclaration]
subitems_values: CollectionProperty(type=StrProperty)
# Attribute parameters.
is_optional: BoolProperty(name="Is Optional")
@@ -342,7 +342,7 @@ class Attribute(PropertyGroup):
value_max: FloatProperty(description="This is used to validate int_value and float_value")
value_max_constraint: BoolProperty(default=False, description="True if the numerical value has an upper bound")
special_type: StringProperty(name="Special Value Type", default="")
use_explorer_ui: BoolProperty() # pyright: ignore[reportRedeclaration]
use_explorer_ui: BoolProperty()
metadata: StringProperty(name="Metadata", description="For storing some additional information about the attribute")
update: StringProperty(name="Update", description="Custom update function to be executed")
+1 -1
View File
@@ -665,7 +665,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences):
name="Disable Undo When Saving (Faster saves, no undo for you!)", default=False
)
should_stream: BoolProperty(name="Stream Data From IFC-SPF (Only for advanced users)", default=False)
should_always_cache: BoolProperty( # pyright: ignore[reportRedeclaration]
should_always_cache: BoolProperty(
name="Always Cache Geometry",
description="Whether to always cache geometry regardless of 'Cache' setting during Advanced Project Load.",
)
+5 -2
View File
@@ -151,8 +151,11 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t
conflicts = ifcgit.git_mergetool(mergetool, path_ifc)
if conflicts is not None:
ifcgit.git_merge_abort()
ifcgit.store_merge_conflicts(conflicts)
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
if conflicts:
ifcgit.store_merge_conflicts(conflicts)
operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below")
else:
operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly")
return False
ifcgit.commit_merge(path_ifc)
+1 -1
View File
@@ -1888,7 +1888,7 @@ class Blender(bonsai.core.tool.Blender):
@classmethod
def get_radiance_exporter_props(cls) -> RadianceExporterProperties:
assert (scene := bpy.context.scene)
return scene.BIMRadianceExporeterProperies # pyright: ignore[reportAttributeAccessIssue]
return scene.BIMRadianceExporterProperties # pyright: ignore[reportAttributeAccessIssue]
@classmethod
def get_fm_props(cls) -> BIMFMProperties:
+20 -10
View File
@@ -217,7 +217,7 @@ class IfcGit:
rev=[props.display_branch],
)
)
commits_relevant = list(
commits_relevant = set(
git.objects.commit.Commit.iter_items(
repo=repo,
rev=[props.display_branch],
@@ -225,11 +225,17 @@ class IfcGit:
)
)
def is_relevant(commit):
if commit in commits_relevant:
return True
# Merge commits are relevant too
return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents)
for commit in commits:
if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup:
continue
elif props.ifcgit_filter == "relevant" and commit not in commits_relevant:
elif props.ifcgit_filter == "relevant" and not is_relevant(commit):
continue
props.ifcgit_commits.add()
@@ -239,7 +245,7 @@ class IfcGit:
list_item.author_name = commit.author.name
list_item.author_email = commit.author.email
list_item.committed_date = int(commit.committed_date)
if commit in commits_relevant:
if is_relevant(commit):
list_item.relevant = True
if commit.hexsha in lookup:
for tag in lookup[commit.hexsha]:
@@ -280,11 +286,11 @@ class IfcGit:
if re.match("^Ifc", obj.name):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.data.orphans_purge(do_recursive=True) # ty:ignore[unknown-argument]
bpy.data.orphans_purge(do_recursive=True)
from bonsai.bim.module.root.data import IfcClassData
from bonsai.bim.module.model.data import AuthoringData
import bonsai.bim.handler
from bonsai.bim.module.model.data import AuthoringData
from bonsai.bim.module.root.data import IfcClassData
AuthoringData.type_thumbnails = {}
@@ -589,7 +595,7 @@ class IfcGit:
"""Attempt a git merge. Returns None on clean merge, 'conflict' on expected
GitCommandError, or 'error' on an unknown GitError."""
repo = IfcGitRepo.repo
branch = repo.branches[branch_name]
branch = repo.refs[branch_name]
try:
repo.git.merge(branch)
return None
@@ -603,7 +609,7 @@ class IfcGit:
"""Attempt a git merge without committing (always leaves a merge state to abort).
Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure."""
repo = IfcGitRepo.repo
branch = repo.branches[branch_name]
branch = repo.refs[branch_name]
try:
repo.git.merge(branch, no_commit=True, no_ff=True)
return None
@@ -619,8 +625,8 @@ class IfcGit:
report_path = path_ifc + ".ifcmerge"
try:
repo.git.mergetool(tool=mergetool)
except git.exc.GitCommandError:
pass
except git.exc.GitCommandError as e:
print(f"ifcgit: mergetool failed: {e}")
conflicts = None
if os.path.exists(report_path):
@@ -636,6 +642,10 @@ class IfcGit:
os.remove(report_path)
except OSError:
pass
if conflicts is None and repo.index.unmerged_blobs():
conflicts = []
return conflicts
@classmethod
+2 -3
View File
@@ -25,13 +25,12 @@ import bmesh
import bpy
import mathutils
import numpy as np
from mathutils import Vector
from bpy_extras import view3d_utils
from mathutils import Vector
import bonsai.core.tool
import bonsai.tool as tool
from bpy_extras import view3d_utils
class Raycast(bonsai.core.tool.Raycast):
offset = 10
@@ -7,7 +7,7 @@ Python code formatters
For Python code formatting, we use `Black code formatter <https://pypi.org/project/black/>`__,
black settings are stored in the repository's pyproject.toml.
We have GitHub workflow `ci-black-formatting` to maintain black formatting across the repository.
We have GitHub workflow `ci-lint` to maintain black formatting across the repository.
``black`` can be installed using ``pip install black`` and files can be formatted with the following example command:
@@ -13,7 +13,7 @@ When adding or removing a supported Python version, update the following:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_IOS_PY_VERSION``
* - ``.github/workflows/ci-ifcopenshell-python-pypi.yml``
- ``pyver`` matrix
@@ -44,6 +44,8 @@ When a new Blender version is released and supported:
* - File
- What to update
* - ``.github/workflows/ci-bonsai.yml``
- ``pyver`` matrix
* - ``.github/workflows/ci-bonsai-daily.yml``
- Blender download URL
@@ -57,9 +59,47 @@ When Blender ships with a new Python version:
* - File
- What to update
* - ``.github/workflows/ci-black-formatting.yaml``
* - ``.github/workflows/ci-lint.yaml``
- ``MIN_BLENDER_PY_VERSION``
* - ``src/bonsai/Makefile``
- ``SUPPORTED_PYVERSIONS``
* - ``src/bonsai/scripts/dev_environment.py``
- ``PYTHON_VERSION`` mapping (Blender version, bundled Python version)
Release
-------
Notes:
- Typically all packages are released at once using the same version schema
- The ``README.md`` badges can serve as a visual reference for what versions have been released
Things to update:
- ``.github/workflows/ci-bcf-pypi.yml`` - release `bcf-client <https://pypi.org/project/bcf-client/>`_ to PyPI
- ``.github/workflows/ci-bonsai.yml`` - release bonsai in GitHub releases
- ``.github/workflows/ci-bsdd-pypi.yaml`` - release `bsdd <https://pypi.org/project/bsdd/>`_ to PyPI
- ``.github/workflows/ci-ifc4d-pypi.yaml`` - release `ifc4d <https://pypi.org/project/ifc4d/>`_ to PyPI
- ``.github/workflows/ci-ifc5d-pypi.yaml`` - release `ifc5d <https://pypi.org/project/ifc5d/>`_ to PyPI
- ``.github/workflows/ci-ifcclash-pypi.yaml`` - release `ifcclash <https://pypi.org/project/ifcclash/>`_ to PyPI
- ``.github/workflows/ci-ifcconvert.yml`` - release ifcconvert binaries in GitHub releases
- ``.github/workflows/ci-ifccsv-pypi.yaml`` - release `ifccsv <https://pypi.org/project/ifccsv/>`_ to PyPI
- ``.github/workflows/ci-ifcdiff-pypi.yaml`` - release `ifcdiff <https://pypi.org/project/ifcdiff/>`_ to PyPI
- ``.github/workflows/ci-ifcedit-pypi.yaml`` - release `ifcedit <https://pypi.org/project/ifcedit/>`_ to PyPI
- ``.github/workflows/ci-ifcfm-pypi.yaml`` - release `ifcfm <https://pypi.org/project/ifcfm/>`_ to PyPI
- ``.github/workflows/ci-ifccityjson-pypi.yaml`` - release `ifccityjson <https://pypi.org/project/ifccityjson/>`_ to PyPI
- ``.github/workflows/ci-ifcmcp-pypi.yaml`` - release `ifcopenshell-mcp <https://pypi.org/project/ifcopenshell-mcp/>`_ to PyPI
- ``.github/workflows/ci-ifcopenshell-python.yml`` - release ifcopenshell-python binaries in GitHub releases
- ``.github/workflows/ci-ifcopenshell-python-pypi.yml`` - release `ifcopenshell <https://pypi.org/project/ifcopenshell/>`_ wheels to PyPI
- ``.github/workflows/ci-ifcpatch-pypi.yaml`` - release `ifcpatch <https://pypi.org/project/ifcpatch/>`_ to PyPI
- ``.github/workflows/ci-ifcquery-pypi.yaml`` - release `ifcquery <https://pypi.org/project/ifcquery/>`_ to PyPI
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
- Release Bonsai Blender extension - zip files from ci-bonsai.yml releases should be uploaded manually to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
- Publishing documentation and websites (see `website <https://github.com/IfcOpenShell/website>`_ repository):
- `ifcopenshell-docs.yml` - builds and publishes IfcOpenShell documentation to `docs.ifcopenshell.org <https://docs.ifcopenshell.org>`_ (`ifcopenshell_org_docs <https://github.com/IfcOpenShell/ifcopenshell_org_docs>`_ repo)
- `bonsai-docs.yml` - builds and publishes Bonsai documentation to `docs.bonsaibim.org <https://docs.bonsaibim.org>`_ (`bonsaibim_org_docs <https://github.com/IfcOpenShell/bonsaibim_org_docs>`_ repo)
- `main.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
- ``VERSION`` to the release version - **UPDATE THIS LAST** as all workflows above typically depend on it to set the version correctly
+1 -1
View File
@@ -20,7 +20,7 @@
import pytest
import bonsai.core.ifcgit as subject
from test.core.bootstrap import ifcgit, ifc
from test.core.bootstrap import ifc, ifcgit
class MockOperator:
+123 -4
View File
@@ -139,6 +139,7 @@ const baseUrlRowEl = $("baseUrlRow");
const baseUrlLabelEl = $("baseUrlLabel");
const baseUrlEl = $("baseUrl");
const thinkingIndicatorEl = $("thinkingIndicator");
const compactingIndicatorEl = $("compactingIndicator");
const modelEl = $("model");
const providerEls = document.querySelectorAll('input[name="provider"]');
const ifcFileEl = $("ifcFile");
@@ -395,6 +396,7 @@ function addMessage(role, text) {
function setStatus(text) {
statusEl.textContent = text;
thinkingIndicatorEl.hidden = text !== "Thinking…";
compactingIndicatorEl.hidden = text !== "Compacting…";
msgsEl.scrollTop = msgsEl.scrollHeight;
}
@@ -491,6 +493,7 @@ Rules:
- If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree.
- If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit.
- If there is no model and the user wants to create one, call ifc_new.
- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement).
- After edits, explain what changed and suggest downloading the IFC.
Be concise. Avoid dumping huge trees unless asked.
`;
@@ -499,6 +502,11 @@ let messages = []; // running conversation state (Chat Completions style)
const MAX_TOOL_RESULT_CHARS = 0;
const MAX_HISTORY_MESSAGES = 40;
const ESTIMATED_CHARS_PER_TOKEN = 4;
const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000;
const COMPACT_WHEN_ESTIMATED_TOKENS = 18000;
const KEEP_RAW_TURN_GROUPS = 1;
const minuteTokenMap = new Map();
function truncateToolResult(text) {
if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text;
@@ -518,6 +526,103 @@ function trimHistory() {
}
}
function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) {
return Array.from(minuteTokenMap.entries())
.filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket)
.sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket)
.map(([minuteBucket, estimatedTokens]) => ({
timestamp: new Date(minuteBucket * 60000).toISOString(),
estimated_tokens: estimatedTokens,
}));
}
async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) {
const estimatedTokens = Math.max(
1,
Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN)
);
let currentMinuteBucket = Math.floor(Date.now() / 60000);
const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens;
if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) {
currentMinuteBucket += 1;
await new Promise((resolve) => setTimeout(() => resolve(), 60000));
}
minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens);
return {
minuteBucket: currentMinuteBucket,
response: await chat({ apiKey, baseURL, model, messages, tools }),
};
}
async function compactHistoryWithLLM(chat, apiKey, baseURL, model) {
const estimatedTokens = Math.max(
1,
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
);
if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null;
const { prefix, groups } = messages.reduce((acc, message) => {
if (message.role === "user") {
acc.groups.push([message]);
} else if (acc.groups.length) {
acc.groups[acc.groups.length - 1].push(message);
} else {
acc.prefix.push(message);
}
return acc;
}, { prefix: [], groups: [] });
if (groups.length <= KEEP_RAW_TURN_GROUPS) return null;
const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()];
if (!compacted.length) return null;
setStatus("Compacting…");
try {
const before = {
message_count: messages.length,
turn_group_count: groups.length,
estimated_tokens: estimatedTokens,
};
const { minuteBucket, response } = await chatWithMinuteDelay({
chat,
apiKey,
baseURL,
model,
messages: [
{
role: "system",
content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary."
},
{ role: "user", content: JSON.stringify(compacted) },
],
});
const summary = response.choices?.[0]?.message?.content?.trim();
if (!summary) return minuteBucket;
messages = [
{ role: "assistant", content: `[Context summary]\n${summary}` },
...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(),
];
console.log("History compaction before", before);
console.log("History compaction after", {
message_count: messages.length,
turn_group_count: messages.filter((message) => message.role === "user").length,
estimated_tokens: Math.max(
1,
Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN)
),
});
return minuteBucket;
} finally {
setStatus("Thinking…");
}
}
async function runAgentTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
@@ -525,18 +630,29 @@ async function runAgentTurn(userText) {
const provider = PROVIDERS[getProviderValue()];
const { chat } = provider.api;
const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined;
let firstIterationMinuteBucket = null;
messages.push({ role: "user", content: userText });
trimHistory();
for (let i = 0; i < 64; i++) {
const response = await chat({
const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value);
if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) {
firstIterationMinuteBucket = compactedMinuteBucket;
}
if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory();
const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages];
const { minuteBucket, response } = await chatWithMinuteDelay({
chat,
apiKey,
baseURL,
model: modelEl.value,
messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages],
messages: messages_with_system,
tools,
});
if (firstIterationMinuteBucket === null) {
firstIterationMinuteBucket = minuteBucket;
}
const message = response.choices?.[0]?.message;
if (!message) throw new Error("No message in response");
@@ -546,7 +662,10 @@ async function runAgentTurn(userText) {
if (message.content) addMessage("assistant", message.content);
const calls = message.tool_calls ?? [];
if (calls.length === 0) return;
if (calls.length === 0) {
console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket));
return;
}
for (const call of calls) {
let args = {};
+4
View File
@@ -111,6 +111,10 @@
<span class="spinner"></span>
<span>thinking...</span>
</div>
<div class="thinking-indicator" id="compactingIndicator" hidden>
<span class="spinner"></span>
<span>compacting...</span>
</div>
</div>
<div class="composer">
<div class="inner">
@@ -52,15 +52,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcPointByDistanceExpression* i
if (inst->OffsetVertical().has_value()) {
auto offset_vertical = inst->OffsetVertical().get() * length_unit_;
o += offset_vertical * z;
auto tmp1 = (z * offset_vertical).eval();
auto tmp2 = (Eigen::Vector3d(0, 0, 1) * offset_vertical).eval();
auto tmp3 = (tmp1 - tmp2).eval();
std::ostringstream oss;
oss << "local z: " << z.x() << "," << z.y() << "," << z.z() << "; delta: " << tmp3.x() << "," << tmp3.y() << "," << tmp3.z();
auto osss = oss.str();
std::wcout << osss.c_str() << std::endl;
}
if (inst->OffsetLongitudinal().has_value()) {
+2 -2
View File
@@ -8,10 +8,10 @@ sessions.
## Installation
```bash
pip install ifcmcp
pip install ifcopenshell-mcp
```
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcmcp[mcp]` or add `mcp` separately.
Requires `ifcopenshell`, `ifcquery`, and `ifcedit`. The `mcp` package is an optional dependency needed to run the server; install it with `pip install ifcopenshell-mcp[mcp]` or add `mcp` separately.
## Running the server
+12 -2
View File
@@ -278,7 +278,12 @@ class IfcSession:
return info.info(model, element)
def ifc_select(self, query: str) -> list[dict[str, Any]]:
"""Filter elements using ifcopenshell selector syntax (e.g. 'IfcWall', 'IfcWindow')."""
"""Filter elements using ifcopenshell selector syntax.
Examples: ``IfcWall``, ``IfcWall, IfcColumn``, ``! IfcWall``,
``IfcWall, Name = "My Wall"``, ``type = "Concrete Wall"``,
``material = "Concrete"``.
"""
return select.select(self._require_model(), query)
def ifc_relations(self, element_id: int, traverse: str = "") -> dict[str, Any] | list[dict[str, Any]]:
@@ -536,7 +541,12 @@ class IfcSession:
{
"type": "function",
"name": "ifc_select",
"description": "Select elements using ifcopenshell selector syntax (e.g. 'IfcWall').",
"description": (
"Select elements using ifcopenshell selector syntax. "
"Examples: 'IfcWall', 'IfcWall, IfcColumn', '! IfcWall', "
"'IfcWall, Name = \"My Wall\"', 'type = \"Concrete Wall\"', "
"'material = \"Concrete\"'."
),
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
@@ -469,7 +469,7 @@ def get_properties(
del data["HasProperties"]
results[prop_name] = data
if verbose:
results[prop_name] = {"id": data["id"], "class": data["class"], "value": results[prop_name]}
results[prop_name] = {"id": data["id"], "class": data["type"], "value": results[prop_name]}
return results
@@ -307,6 +307,35 @@ class TestGetPropertiesIFC4(test.bootstrap.IFC4):
}
}
def test_getting_complex_properties_verbose(self):
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="pset")
complex_property = self.file.create_entity("IfcComplexProperty", Name="prop", UsageName="usage_name")
ifcopenshell.api.pset.edit_pset(self.file, pset=complex_property, properties={"a": "b"})
pset.HasProperties = [complex_property]
properties = subject.get_properties(pset.HasProperties, verbose=True)
prop_value = properties["prop"]["value"]
nested_prop = prop_value["properties"]["a"]
assert properties == {
"prop": {
"id": complex_property.id(),
"class": "IfcComplexProperty",
"value": {
"UsageName": "usage_name",
"id": complex_property.id(),
"type": "IfcComplexProperty",
"properties": {
"a": {
"id": nested_prop["id"],
"class": "IfcPropertySingleValue",
"value": "b",
"value_type": "IfcLabel",
}
},
},
}
}
class TestGetElementsUsingPset(test.bootstrap.IFC4):
def test_run(self):
+10 -1
View File
@@ -26,7 +26,16 @@ import ifcopenshell.util.selector
def select(model: ifcopenshell.file, query: str) -> list[dict[str, Any]]:
"""Filter elements using selector syntax and return matching element summaries."""
"""Filter elements using ifcopenshell selector syntax and return matching element summaries.
Examples:
- ``IfcWall`` all walls
- ``IfcWall, IfcColumn`` walls and columns
- ``! IfcWall`` everything except walls
- ``IfcWall, Name = "My Wall"`` walls with a specific name attribute
- ``type = "Concrete Wall"`` elements assigned that type product
- ``material = "Concrete"`` elements with that material
"""
elements = ifcopenshell.util.selector.filter_elements(model, query)
results = []
for element in sorted(elements, key=lambda e: e.id()):
@@ -32,7 +32,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv
bl_idname = "SvIfcSbExtrude"
bl_label = "IFC Extrude"
extrude_axis: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration]
extrude_axis: bpy.props.EnumProperty(
default="Z",
items=[
("X", "X", "Interpret curve as in XY plane and extrude along X+."),
+4 -4
View File
@@ -33,7 +33,7 @@
"tailwindcss": "^4.0.0",
"tw-animate-css": "^1.3.2",
"typescript": "^5.8.3",
"vite": "^6.4.1"
"vite": "^6.4.2"
}
},
"node_modules/@ampproject/remapping": {
@@ -3264,9 +3264,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -30,7 +30,7 @@
"tailwindcss": "^4.0.0",
"typescript": "^5.8.3",
"tw-animate-css": "^1.3.2",
"vite": "^6.4.1"
"vite": "^6.4.2"
},
"dependencies": {
"eventemitter3": "^5.0.1",
+716 -94
View File
@@ -615,10 +615,7 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS
std::swap(poly1, poly2);
}
std::cerr << "processing: " << edge.first << " " << edge.second << std::endl;
std::cerr << "area before: " << poly1->area() << " " << poly2->area() << std::endl;
bool is_ = edge == std::make_pair(25, 27);
bool is_ = edge == std::make_pair<size_t, size_t>(25, 27);
bool success = false;
if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) {
@@ -657,8 +654,6 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS
}
}
std::cerr << "area after: " << poly1->area() << " " << poly2->area() << std::endl;
if (!success) {
eliminated_polies.insert(swap ? edge.first : edge.second);
continue;
@@ -911,6 +906,681 @@ build_line_graph(const std::vector<Polygon_2>& input_polygons, SegmentLookup& se
return {line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length};
}
using DPoint = CGAL::Simple_cartesian<double>::Point_2;
using DDir = CGAL::Simple_cartesian<double>::Vector_2;
using DBox = std::array<DPoint, 2>;
struct CenterLineGraphData {
std::vector<Point_2> points;
std::vector<DPoint> points_double;
std::vector<double> widths;
std::vector<std::pair<size_t, size_t>> edges;
std::vector<std::vector<size_t>> incident_edges;
};
struct LineRun {
Point_2 start_exact;
Point_2 end_exact;
DPoint start;
DPoint end;
DDir direction;
double avg_width;
double length;
size_t vertex_count;
};
struct RunBoxRecord {
size_t run_index;
DPoint start;
DPoint end;
DDir direction;
double width;
double length;
std::array<DPoint, 4> corners;
DBox bbox;
};
struct MergedBoxRecord {
DPoint start;
DPoint end;
DDir direction;
DDir normal;
double avg_width;
double length;
size_t member_count;
std::vector<size_t> members;
std::array<DPoint, 4> corners;
DBox bbox;
Point_2 exact_start;
Point_2 exact_end;
};
struct BoxCluster {
std::vector<size_t> members;
MergedBoxRecord box;
};
struct SnapCandidate {
size_t box_index;
double box_distance;
double line_distance;
Point_2 projection;
};
DDir unit(const DDir& a) {
auto n = std::sqrt(a.squared_length());
if (n < 1.e-9) {
return {0., 0.};
}
return a / n;
}
DDir perpendicular(const DDir& a) {
return DDir(-a.y(), a.x());
}
DDir canonicalize_like(const DDir& a, const DDir& ref) {
return (a * ref) < 0. ? -a : a;
}
DPoint to_double_point(const Point_2& p) {
return {CGAL::to_double(p.x()), CGAL::to_double(p.y())};
}
Point_2 to_exact_point(const DPoint& p) {
return Point_2(p.x(), p.y());
}
double point_line_distance(const DPoint& p, const DPoint& line_point, const DDir& line_dir) {
auto u = unit(line_dir);
auto delta = (p - line_point);
if (u.squared_length() < 1.e-18) {
return std::sqrt(delta.squared_length());
}
return std::abs(CGAL::determinant(u.x(), u.y(), delta.x(), delta.y()));
}
double angle_between_dirs_deg(const DDir& a, const DDir& b) {
auto u = unit(a);
auto v = unit(b);
auto c = std::abs(u * v);
if (c > 1.) {
c = 1.;
}
return std::acos(c) * 180. / 3.14159265358979323846;
}
std::array<DPoint, 4> rectangle_corners(const DPoint& start, const DPoint& end, double width) {
auto u = unit(end - start);
if (u.squared_length() < 1.e-18) {
u = {1., 0.};
}
auto n = perpendicular(u);
auto ext = width;
auto p0 = start - u * ext;
auto p1 = end + u * ext;
auto w = n * (width / 2.);
return {p0 + w, p1 + w, p1 - w, p0 - w};
}
DBox aabb_from_points(const std::array<DPoint, 4>& corners) {
DBox bbox{corners[0], corners[0]};
for (auto& p : corners) {
bbox[0] = {std::min(bbox[0].x(), p.x()), std::min(bbox[0].y(), p.y())};
bbox[1] = {std::max(bbox[1].x(), p.x()), std::max(bbox[1].y(), p.y())};
}
return bbox;
}
bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) {
return a[0].x() <= b[1].x() + eps &&
a[1].x() + eps >= b[0].x() &&
a[0].y() <= b[1].y() + eps &&
a[1].y() + eps >= b[0].y();
}
CenterLineGraphData make_center_line_graph_data(
const std::map<Point_2, std::vector<Point_2>>& line_graph,
const std::map<Point_2, double>& midpoint_to_edge_length)
{
CenterLineGraphData graph;
std::map<Point_2, size_t> point_to_index;
auto ensure_point = [&](const Point_2& p) {
auto it = point_to_index.find(p);
if (it != point_to_index.end()) {
return it->second;
}
auto i = graph.points.size();
point_to_index[p] = i;
graph.points.push_back(p);
graph.points_double.push_back(to_double_point(p));
auto wt = midpoint_to_edge_length.find(p);
graph.widths.push_back(wt == midpoint_to_edge_length.end() ? 0. : wt->second);
graph.incident_edges.emplace_back();
return i;
};
for (auto& p : line_graph) {
ensure_point(p.first);
for (auto& q : p.second) {
ensure_point(q);
}
}
std::set<std::pair<size_t, size_t>> seen_edges;
for (auto& p : line_graph) {
auto i = ensure_point(p.first);
for (auto& q : p.second) {
auto j = ensure_point(q);
if (i == j) {
continue;
}
auto e = i < j ? std::make_pair(i, j) : std::make_pair(j, i);
if (seen_edges.insert(e).second) {
auto k = graph.edges.size();
graph.edges.push_back(e);
graph.incident_edges[e.first].push_back(k);
graph.incident_edges[e.second].push_back(k);
}
}
}
return graph;
}
double segment_width(const CenterLineGraphData& graph, const std::pair<size_t, size_t>& edge) {
return 0.5 * (graph.widths[edge.first] + graph.widths[edge.second]);
}
bool edge_supports_same_line(
const DPoint& seed_a,
const DPoint& seed_b,
const DPoint& test_a,
const DPoint& test_b,
double angle_tol_deg = 3.,
double line_dist_tol = 0.15)
{
auto d_seed = seed_b - seed_a;
auto d_test = test_b - test_a;
if (d_seed.squared_length() < 1.e-18 || d_test.squared_length() < 1.e-18) {
return false;
}
if (angle_between_dirs_deg(d_seed, d_test) > angle_tol_deg) {
return false;
}
return
point_line_distance(test_a, seed_a, d_seed) <= line_dist_tol &&
point_line_distance(test_b, seed_a, d_seed) <= line_dist_tol;
}
std::vector<LineRun> runs_from_graph(const CenterLineGraphData& graph, double angle_tol_deg = 3., double line_dist_tol = 0.15) {
std::vector<bool> visited(graph.edges.size(), false);
std::vector<LineRun> runs;
for (size_t seed_ei = 0; seed_ei < graph.edges.size(); ++seed_ei) {
if (visited[seed_ei]) {
continue;
}
const auto& seed_edge = graph.edges[seed_ei];
auto seed_a = graph.points_double[seed_edge.first];
auto seed_b = graph.points_double[seed_edge.second];
auto seed_dir = seed_b - seed_a;
if (seed_dir.squared_length() < 1.e-18) {
visited[seed_ei] = true;
continue;
}
std::vector<size_t> queue = {seed_ei};
std::set<size_t> component_edges;
while (!queue.empty()) {
auto ei = queue.back();
queue.pop_back();
if (!component_edges.insert(ei).second) {
continue;
}
const auto& edge = graph.edges[ei];
std::array<size_t, 2> vertices = {edge.first, edge.second};
for (auto v : vertices) {
for (auto ej : graph.incident_edges[v]) {
if (ej == ei || visited[ej] || component_edges.count(ej)) {
continue;
}
const auto& candidate = graph.edges[ej];
auto test_a = graph.points_double[candidate.first];
auto test_b = graph.points_double[candidate.second];
if (edge_supports_same_line(seed_a, seed_b, test_a, test_b, angle_tol_deg, line_dist_tol)) {
queue.push_back(ej);
}
}
}
}
for (auto ei : component_edges) {
visited[ei] = true;
}
std::set<size_t> component_vertices;
auto ref = unit(seed_dir);
DDir direction_sum{0., 0.};
double total_length = 0.;
double weighted_width_sum = 0.;
for (auto ei : component_edges) {
const auto& edge = graph.edges[ei];
component_vertices.insert(edge.first);
component_vertices.insert(edge.second);
auto d = graph.points_double[edge.second] - graph.points_double[edge.first];
auto u = canonicalize_like(unit(d), ref);
direction_sum = direction_sum + u;
auto len = std::sqrt(d.squared_length());
total_length += len;
weighted_width_sum += len * segment_width(graph, edge);
}
auto run_direction = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum);
double min_t = std::numeric_limits<double>::infinity();
double max_t = -std::numeric_limits<double>::infinity();
size_t start_index = *component_vertices.begin();
size_t end_index = start_index;
for (auto vi : component_vertices) {
auto t = (graph.points_double[vi] - CGAL::ORIGIN) * run_direction;
if (t < min_t) {
min_t = t;
start_index = vi;
}
if (t > max_t) {
max_t = t;
end_index = vi;
}
}
auto avg_width = total_length < 1.e-9 ? segment_width(graph, seed_edge) : weighted_width_sum / total_length;
runs.push_back({
graph.points[start_index],
graph.points[end_index],
graph.points_double[start_index],
graph.points_double[end_index],
run_direction,
avg_width,
std::sqrt((graph.points_double[end_index] - graph.points_double[start_index]).squared_length()),
component_vertices.size()
});
}
return runs;
}
std::vector<RunBoxRecord> build_run_box_records(const std::vector<LineRun>& runs) {
std::vector<RunBoxRecord> records;
records.reserve(runs.size());
for (size_t i = 0; i < runs.size(); ++i) {
auto corners = rectangle_corners(runs[i].start, runs[i].end, runs[i].avg_width);
records.push_back({
i,
runs[i].start,
runs[i].end,
unit(runs[i].end - runs[i].start),
runs[i].avg_width,
runs[i].length,
corners,
aabb_from_points(corners)
});
}
return records;
}
template <typename T>
std::pair<double, double> projected_interval_on_axis(const T& box, const DDir& axis_u) {
auto u = unit(axis_u);
auto ta = (box.start - CGAL::ORIGIN) * u;
auto tb = (box.end - CGAL::ORIGIN) * u;
return {std::min(ta, tb), std::max(ta, tb)};
}
double interval_overlap_length(const std::pair<double, double>& a, const std::pair<double, double>& b) {
return std::max(0., std::min(a.second, b.second) - std::max(a.first, b.first));
}
template <typename T>
double boxes_overlap_along_merge_axis(const T& a, const T& b) {
auto d1 = unit(a.end - a.start);
auto d2 = unit(b.end - b.start);
if (d1 * d2 < 0.) {
d2 = {-d2.x(), -d2.y()};
}
auto merge_axis = unit(d1 + d2);
if (merge_axis.squared_length() < 1.e-18) {
merge_axis = d1;
}
auto i1 = projected_interval_on_axis(a, merge_axis);
auto i2 = projected_interval_on_axis(b, merge_axis);
auto overlap = interval_overlap_length(i1, i2);
auto small_length = std::min(i1.second - i1.first, i2.second - i2.first);
if (small_length < 1.e-9) {
return false;
}
return overlap / small_length;
}
MergedBoxRecord merge_cluster_to_box(const std::vector<size_t>& member_indices, const std::vector<RunBoxRecord>& records) {
auto ref = records[member_indices.front()].direction;
DDir direction_sum{0., 0.};
for (auto i : member_indices) {
auto u = canonicalize_like(records[i].direction, ref);
direction_sum = direction_sum + u * std::max(records[i].length, 1.e-9);
}
auto u = direction_sum.squared_length() < 1.e-18 ? ref : unit(direction_sum);
auto n = perpendicular(u);
double tmin = std::numeric_limits<double>::infinity();
double tmax = -std::numeric_limits<double>::infinity();
double smin = std::numeric_limits<double>::infinity();
double smax = -std::numeric_limits<double>::infinity();
for (auto i : member_indices) {
for (auto& corner : records[i].corners) {
auto t = (corner - CGAL::ORIGIN) * u;
auto s = (corner - CGAL::ORIGIN) * n;
tmin = std::min(tmin, t);
tmax = std::max(tmax, t);
smin = std::min(smin, s);
smax = std::max(smax, s);
}
}
auto width = smax - smin;
auto sc = (smin + smax) / 2.;
auto start = u * tmin + n * sc;
auto end = u * tmax + n * sc;
auto corners = rectangle_corners(CGAL::ORIGIN + start, CGAL::ORIGIN + end, width);
MergedBoxRecord box{
CGAL::ORIGIN + start,
CGAL::ORIGIN + end,
u,
n,
width,
std::sqrt((end - start).squared_length()),
member_indices.size(),
member_indices,
corners,
aabb_from_points(corners),
to_exact_point(CGAL::ORIGIN + start),
to_exact_point(CGAL::ORIGIN + end)
};
return box;
}
std::pair<double, double> merge_score(const MergedBoxRecord& a, const MergedBoxRecord& b) {
auto ang = angle_between_dirs_deg(a.direction, b.direction);
auto center_a = ((a.start - CGAL::ORIGIN) + (a.end - CGAL::ORIGIN)) / 2.;
auto center_b = ((b.start - CGAL::ORIGIN) + (b.end - CGAL::ORIGIN)) / 2.;
return {ang, std::sqrt((center_b - center_a).squared_length())};
}
bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_tol_deg = 5., double axis_overlap_ratio_limit = 0.5) {
if (!aabb_overlap(a.box.bbox, b.box.bbox)) {
return false;
}
if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) {
return false;
}
if (boxes_overlap_along_merge_axis(a.box, b.box) > axis_overlap_ratio_limit) {
auto a_center = CGAL::ORIGIN + ((a.box.start - CGAL::ORIGIN) + (a.box.end - CGAL::ORIGIN)) / 2.;
auto b_center = CGAL::ORIGIN + ((b.box.start - CGAL::ORIGIN) + (b.box.end - CGAL::ORIGIN)) / 2.;
auto a_dir = a.box.direction;
auto b_dir = b.box.direction;
auto dist = a.box.length < b.box.length ? point_line_distance(a_center, b_center, b_dir) : point_line_distance(b_center, a_center, a_dir);
auto ref = a.box.length < b.box.length ? a.box.avg_width : b.box.avg_width;
return dist < (ref / 4.);
}
return true;
}
std::vector<MergedBoxRecord> merge_intersecting_parallel_boxes_iterative(const std::vector<LineRun>& runs) {
auto records = build_run_box_records(runs);
std::vector<BoxCluster> clusters;
clusters.reserve(records.size());
for (size_t i = 0; i < records.size(); ++i) {
clusters.push_back({{i}, merge_cluster_to_box({i}, records)});
}
while (true) {
std::optional<std::pair<size_t, size_t>> best_pair;
std::pair<double, double> best_score;
for (size_t i = 0; i < clusters.size(); ++i) {
for (size_t j = i + 1; j < clusters.size(); ++j) {
if (!clusters_can_merge(clusters[i], clusters[j])) {
continue;
}
auto score = merge_score(clusters[i].box, clusters[j].box);
if (!best_pair || score < best_score) {
best_pair = std::make_pair(i, j);
best_score = score;
}
}
}
if (!best_pair) {
break;
}
auto i = best_pair->first;
auto j = best_pair->second;
std::vector<size_t> members = clusters[i].members;
members.insert(members.end(), clusters[j].members.begin(), clusters[j].members.end());
auto merged = BoxCluster{members, merge_cluster_to_box(members, records)};
std::vector<BoxCluster> next_clusters;
next_clusters.reserve(clusters.size() - 1);
for (size_t k = 0; k < clusters.size(); ++k) {
if (k != i && k != j) {
next_clusters.push_back(std::move(clusters[k]));
}
}
next_clusters.push_back(std::move(merged));
clusters = std::move(next_clusters);
}
std::vector<MergedBoxRecord> merged_boxes;
merged_boxes.reserve(clusters.size());
for (auto& cluster : clusters) {
merged_boxes.push_back(cluster.box);
}
return merged_boxes;
}
Point_2 project_point_to_line_exact(const Point_2& p, const MergedBoxRecord& box) {
auto d = box.exact_end - box.exact_start;
if (d.squared_length() == 0) {
return box.exact_start;
}
auto t = ((p - box.exact_start) * d) / d.squared_length();
return box.exact_start + d * t;
}
boost::optional<Point_2> intersect_infinite_lines_exact(const MergedBoxRecord& a, const MergedBoxRecord& b) {
if (a.exact_start == a.exact_end || b.exact_start == b.exact_end) {
return boost::none;
}
auto x = CGAL::intersection(CGAL::Line_2<K>(a.exact_start, a.exact_end), CGAL::Line_2<K>(b.exact_start, b.exact_end));
if (!x) {
return boost::none;
}
if (auto* xp = variant_get<Point_2>(&*x)) {
return *xp;
}
return boost::none;
}
double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& box) {
auto d = box.end - box.start;
auto L = std::sqrt(d.squared_length());
if (L < 1.e-9) {
return std::sqrt((p - box.start).squared_length());
}
auto u = d / L;
auto n = perpendicular(u);
auto rel = p - box.start;
auto t = rel * u;
auto s = rel * n;
auto tmin = -box.avg_width / 2.;
auto tmax = L + box.avg_width / 2.;
auto smin = -box.avg_width / 2.;
auto smax = box.avg_width / 2.;
double dt = 0.;
if (t < tmin) {
dt = tmin - t;
} else if (t > tmax) {
dt = t - tmax;
}
double ds = 0.;
if (s < smin) {
ds = smin - s;
} else if (s > smax) {
ds = s - smax;
}
return std::hypot(dt, ds);
}
std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
const CenterLineGraphData& graph,
const std::vector<MergedBoxRecord>& boxes)
{
std::vector<Point_2> snapped_points(graph.points.size());
for (size_t i = 0; i < graph.points.size(); ++i) {
if (boxes.empty()) {
snapped_points[i] = graph.points[i];
continue;
}
std::vector<SnapCandidate> candidates;
candidates.reserve(boxes.size());
for (size_t j = 0; j < boxes.size(); ++j) {
candidates.push_back({
j,
point_to_oriented_box_distance(graph.points_double[i], boxes[j]),
point_line_distance(graph.points_double[i], boxes[j].start, boxes[j].direction),
project_point_to_line_exact(graph.points[i], boxes[j])
});
}
std::vector<SnapCandidate> containing;
for (auto& candidate : candidates) {
if (candidate.box_distance <= 1.e-9) {
containing.push_back(candidate);
}
}
auto less = [](const SnapCandidate& a, const SnapCandidate& b) {
if (a.line_distance != b.line_distance) {
return a.line_distance < b.line_distance;
}
return a.box_distance < b.box_distance;
};
if (containing.size() >= 2) {
std::sort(containing.begin(), containing.end(), less);
auto& c1 = containing[0];
auto& c2 = containing[1];
if (angle_between_dirs_deg(boxes[c1.box_index].direction, boxes[c2.box_index].direction) > 8.) {
if (auto x = intersect_infinite_lines_exact(boxes[c1.box_index], boxes[c2.box_index])) {
snapped_points[i] = *x;
continue;
}
}
snapped_points[i] = c1.projection;
continue;
}
if (containing.size() == 1) {
snapped_points[i] = containing[0].projection;
continue;
}
auto best = *std::min_element(candidates.begin(), candidates.end(), [](const SnapCandidate& a, const SnapCandidate& b) {
if (a.box_distance != b.box_distance) {
return a.box_distance < b.box_distance;
}
return a.line_distance < b.line_distance;
});
snapped_points[i] = best.projection;
}
std::map<Point_2, std::set<Point_2>> adjacency;
for (auto& edge : graph.edges) {
auto a = snapped_points[edge.first];
auto b = snapped_points[edge.second];
if (a == b) {
continue;
}
adjacency[a].insert(b);
adjacency[b].insert(a);
}
std::map<Point_2, std::vector<Point_2>> snapped_graph;
for (auto& p : adjacency) {
snapped_graph[p.first] = {p.second.begin(), p.second.end()};
}
return snapped_graph;
}
Graph2D<K> join_segment_runs(
DebugWriter& debug,
const std::map<Point_2, std::vector<Point_2>>& line_graph,
const std::map<Point_2, double>& midpoint_to_edge_length)
{
auto graph = make_center_line_graph_data(line_graph, midpoint_to_edge_length);
auto runs = runs_from_graph(graph);
runs.erase(std::remove_if(runs.begin(), runs.end(), [](const LineRun& run) {
return run.vertex_count <= 5;
}), runs.end());
std::vector<Polygon_2> run_polygons;
for (auto& r : runs) {
auto ps = rectangle_corners(r.start, r.end, r.avg_width);
std::array<Point_2, 4> exact_corners;
std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) {
return to_exact_point(p);
});
run_polygons.emplace_back(exact_corners.begin(), exact_corners.end());
}
debug.write_polygons(run_polygons, "initial_runs");
run_polygons.clear();
auto boxes = merge_intersecting_parallel_boxes_iterative(runs);
for (auto& r : boxes) {
auto ps = rectangle_corners(r.start, r.end, r.avg_width);
std::array<Point_2, 4> exact_corners;
std::transform(ps.begin(), ps.end(), exact_corners.begin(), [](const DPoint& p) {
return to_exact_point(p);
});
run_polygons.emplace_back(exact_corners.begin(), exact_corners.end());
}
debug.write_polygons(run_polygons, "merged_boxes");
auto snapped_graph = snap_points_to_box_axes(graph, boxes);
return Graph2D<K>(snapped_graph);
}
std::set<Triangle<K>> find_triangles(const std::map<Point_2, std::vector<Point_2>>& line_graph) {
// Find triangles in this network often occuring at junctions in the corridor mesh
std::set<Triangle<K>> triangles;
@@ -1249,8 +1919,6 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
// create ray incoming -> M
CGAL::Ray_2<K> ray(incoming, M - incoming);
std::cerr << "Extending end vertex " << M << " along ray " << ray << " to boundary of input polygon" << std::endl;
// intersect ray with boundary
boost::optional<CGAL::Segment_2<K>> closest_segment;
boost::optional<CGAL::Point_2<K>> closest_intersection_point;
@@ -1261,7 +1929,6 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
if (x) {
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
auto dist = ((*xp) - M).squared_length();
std::cerr << " - found " << *xp << " on segment " << seg << " with distance " << std::sqrt(CGAL::to_double(dist)) << std::endl;
if (dist < sq_distance_along_ray) {
if (dist < (max_projection_distance * max_projection_distance)) {
closest_segment = seg;
@@ -1329,9 +1996,11 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
auto Pp = seg.supporting_line().projection(M);
if (seg.has_on(Pp)) {
auto d = CGAL::squared_distance(Pp, M);
if (d < closest_distance) {
closest_distance = d;
closest_point = Pp;
if (d < (max_projection_distance * max_projection_distance)) {
if (d < closest_distance) {
closest_distance = d;
closest_point = Pp;
}
}
}
}
@@ -1550,7 +2219,7 @@ std::vector<K::FT> arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ
}
auto res = walk_pl.locate(best_point);
if (auto* v = boost::get<Arrangement_2::Face_const_handle>(&res)) {
if (auto* v = variant_get<Arrangement_2::Face_const_handle>(&res)) {
if (visited_faces_on_right.count(*v) > 0) {
return_values.push_back(0);
} else {
@@ -1651,13 +2320,10 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
return (best + 0.01) / own_length;
};
std::cerr << "badnesses:";
std::map<Segment_2, double, Segment_2_less> badnesses;
for (auto& e : edges) {
badnesses[e] = edge_badness(e);
std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses[e] << ";";
}
std::cerr << std::endl;
{
std::vector<double> tmp;
@@ -1670,8 +2336,6 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
threshold = 4.0 * med;
}
std::cerr << "badness threshold: " << threshold << std::endl;
std::set<Segment_2, Segment_2_less> bad_edges;
for (auto& p : badnesses) {
if (p.second > threshold) {
@@ -1835,14 +2499,6 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
decltype(to_remove) to_remove_this_path;
decltype(to_insert) to_insert_this_path;
std::cerr << "Processing bad path:";
for (size_t i = 0; i < path.size() - 1; ++i) {
auto& a = path[i];
auto& b = path[i + 1];
std::cerr << " (" << a.x() << "," << a.y() << ") - (" << b.x() << "," << b.y() << ");";
}
std::cerr << std::endl;
for (size_t i = 0; i < path.size() - 1; ++i) {
auto& a = path[i];
auto& b = path[i + 1];
@@ -1852,7 +2508,7 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
auto x = collapse_path(path);
if (!x) {
std::cerr << "Unable to collapse path, skipping" << std::endl;
// std::cerr << "Unable to collapse path, skipping" << std::endl;
continue;
}
@@ -1866,16 +2522,14 @@ void clean_noisy_paths(DebugWriter& debug_output, Arrangement_2& arr, SegmentLoo
double new_length = std::sqrt(CGAL::to_double((path.front() - *x).squared_length())) + std::sqrt(CGAL::to_double((path.back() - *x).squared_length()));
if (new_length > orig_length * 2 || orig_length > new_length * 2) {
std::cerr << "Collapsing path would increase length too much, skipping" << std::endl;
// std::cerr << "Collapsing path would increase length too much, skipping" << std::endl;
continue;
}
std::cerr << "new_length: " << new_length << " orig_length: " << orig_length << std::endl;
for (size_t i = 0; i < path.size(); ++i) {
auto& v = path[i];
if (CGAL::squared_distance(v, *x) < 1.e-5) {
std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl;
// std::cerr << "Collapsing path would create near-duplicate vert to previous path, skipping" << std::endl;
continue;
}
}
@@ -2059,7 +2713,6 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
size_t facet_index = 0;
for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it, ++facet_index) {
std::cout << "facet_index " << facet_index << std::endl;
if (!it->is_unbounded()) {
std::set<std::pair<Point_2, Point_2>> to_remove;
std::vector<std::pair<Point_2, Point_2>> to_insert;
@@ -2078,17 +2731,14 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
++circ;
} while (circ != it->outer_ccb());
std::cerr << "badnesses:";
std::vector<double> badnesses;
for (auto& e : segs) {
badnesses.push_back(edge_badness(e));
std::cerr << " (" << e.source().x() << "," << e.source().y() << ") - (" << e.target().x() << "," << e.target().y() << "): " << badnesses.back() << ";";
}
std::cerr << std::endl;
auto bit = std::min_element(badnesses.begin(), badnesses.end());
if (*bit > threshold) {
std::cerr << "All edges are good, skipping" << std::endl;
// std::cerr << "All edges are good, skipping" << std::endl;
continue;
}
@@ -2096,19 +2746,15 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
auto N = circular_distance(it_pair.first, it_pair.second, badnesses);
if (N == 0) {
std::cerr << "Unable to find run of bad edges, skipping" << std::endl;
// std::cerr << "Unable to find run of bad edges, skipping" << std::endl;
continue;
}
std::vector<std::vector<Point_2>> incoming_paths;
std::cout << "range " << std::distance(badnesses.cbegin(), it_pair.first) << " to " << std::distance(badnesses.cbegin(), it_pair.second) << " length " << N << std::endl;
auto jt = it_pair.first;
for (std::size_t k = 0; k < N; ++k, next_circular(jt, badnesses)) {
std::cout << " at " << std::distance(badnesses.cbegin(), jt) << " badness: " << *jt << std::endl;
auto he = halfedges[std::distance(badnesses.cbegin(), jt)];
to_remove.insert({he->source()->point(), he->target()->point()});
debug_output.write_segment(he->source()->point(), he->target()->point(), "arr_bad_bound facet_" + std::to_string(facet_index));
@@ -2175,12 +2821,9 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
CGAL::Ray_2<K> r1((*a)->point(), (*b)->point());
CGAL::Ray_2<K> r2((*d)->point(), (*c)->point());
std::cout << "a: (" << (*a)->point().x() << "," << (*a)->point().y() << ") b: (" << (*b)->point().x() << "," << (*b)->point().y() << ") c: (" << (*c)->point().x() << "," << (*c)->point().y() << ") d: (" << (*d)->point().x() << "," << (*d)->point().y() << ")" << std::endl;
auto x = CGAL::intersection(r1, r2);
if (x) {
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
std::cout << "ray xp: (" << xp->x() << "," << xp->y() << ")" << std::endl;
to_insert.emplace_back((*b)->point(), *xp);
to_insert.emplace_back((*c)->point(), *xp);
@@ -2194,7 +2837,6 @@ void clean_noisy_bounds(DebugWriter& debug_output, Arrangement_2& arr, SegmentLo
auto x = CGAL::intersection(r1, r2);
if (x) {
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
std::cout << "line xp: (" << xp->x() << "," << xp->y() << ")" << std::endl;
to_insert.emplace_back((*b)->point(), *xp);
to_insert.emplace_back((*c)->point(), *xp);
@@ -2495,64 +3137,44 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
}
}
// Write a JSON structure with center line topology with the midpoint_to_edge_length as per-point data
{
std::ofstream ofs("center_line_topology.json");
ofs << "{\n";
ofs << " \"vertices\": [\n";
bool first_vertex = true;
for (auto& p : line_graph) {
if (!first_vertex) {
ofs << ",\n";
}
first_vertex = false;
ofs << " {\n";
ofs << " \"point\": [" << p.first.x() << ", " << p.first.y() << "],\n";
ofs << " \"width\":" << midpoint_to_edge_length.find(p.first)->second << ",\n";
ofs << " \"connected_to\": [\n";
bool first_connected = true;
for (auto& q : p.second) {
if (!first_connected) {
ofs << ",\n";
}
first_connected = false;
ofs << " [" << q.x() << ", " << q.y() << "]";
}
ofs << "\n ]\n";
ofs << " }";
}
ofs << "\n ]\n";
ofs << "}\n";
}
t0.stop();
t0 = timer.start("center line cleaning");
auto triangles = find_triangles(line_graph);
// For every triangle found in the network we eliminate one edge to break the cycle
// The edge we eliminate is the edge with the greatest angle with any of it's neighbours
auto eliminated_segments = eliminate_triangles(line_graph);
Graph2D<K> G;
if (settings.line_cleaning_algo == 0) {
G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length);
Arrangement_2 arr;
G.to_arrangement(arr);
Graph2D<K> G2;
G2.from_arrangement(arr);
eliminate_colinear_vertices(G2);
G = G2;
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
debug_output.write_segment(it->first, it->second, "network_2");
}
} else {
auto eliminated_segments = eliminate_triangles(line_graph);
Graph2D<K> G2(line_graph);
for (auto& e : eliminated_segments) {
debug_output.write_segment(e.first, e.second, "eliminated");
G2.remove_edge(e.first, e.second);
}
Graph2D<K> G2(line_graph);
for (auto& e : eliminated_segments) {
debug_output.write_segment(e.first, e.second, "eliminated");
G2.remove_edge(e.first, e.second);
}
auto G = G2.weld_vertices();
G = G2.weld_vertices();
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
debug_output.write_segment(it->first, it->second, "network_2");
}
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
debug_output.write_segment(it->first, it->second, "network_2");
}
eliminate_colinear_vertices(G);
eliminate_colinear_vertices(G);
edge_slide(G);
edge_slide(G);
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
debug_output.write_segment(it->first, it->second, "network_3");
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
debug_output.write_segment(it->first, it->second, "network_3");
}
}
t0.stop();
@@ -2629,7 +3251,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
fuse_corridor_halves_with_input(arr, G, segment_lookup, input_polygons, debug_output);
}
if (settings.perform_cleanup) {
if (settings.perform_cleanup && settings.line_cleaning_algo != 0) {
remove_colinear_vertices(arr);
double threshold;
clean_noisy_paths(debug_output, arr, segment_lookup, threshold);
+7
View File
@@ -346,6 +346,13 @@ public:
}
}
template <typename T>
void from_arrangement(T& arr) {
for (auto it = arr.edges_begin(); it != arr.edges_end(); ++it) {
insert(it->source()->point(), it->target()->point());
}
}
void assert_symmetric() {
#ifdef SVGFILL_DEBUG
#if 0
+3
View File
@@ -126,6 +126,9 @@ namespace svgfill {
// 0: outer perimiter and corridor center lines
// 1: input polygons, corridor center lines and segments connecting corridor center lines to input polygons
int topology_reconstruction_algo = 0;
// 0: join segment runs
// 1: local badness reduction
int line_cleaning_algo = 0;
bool perform_cleanup = true;
double subdivision_factor = 16.;
};