mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-08 08:51:35 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac18195361 | |||
| 394eb54f80 | |||
| f91f44cd4a | |||
| d835577750 | |||
| fffb444b20 | |||
| a3f212c021 | |||
| c627d59bda | |||
| fd13cd6c13 | |||
| afd6f422ee | |||
| bb661ce998 | |||
| f40377b93d | |||
| 30210b0bf6 | |||
| f7d438f759 | |||
| 8534abe0e0 | |||
| 323d6db4d1 | |||
| 99e20cae64 | |||
| 5e10752bd7 | |||
| 39839cff66 | |||
| a570d81fd7 | |||
| f6e23c0462 | |||
| dcd87260e7 | |||
| 7d148456c0 | |||
| 55568e122d | |||
| 776112bf1d |
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "PyGithub",
|
||||
# "requests",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from github import Github
|
||||
from github.GitReleaseAsset import GitReleaseAsset
|
||||
|
||||
EXTENSION_ID = "bonsai"
|
||||
CURRENT_PYTHON_VERSION = "py313"
|
||||
CURRENT_PLATFORMS = ["linux-x64", "macos-arm64", "windows-x64"]
|
||||
|
||||
|
||||
def publish_asset(asset: GitReleaseAsset, token: str, repo_root: Path) -> None:
|
||||
"""
|
||||
Publish an asset to Blender Extensions.
|
||||
Reference: https://extensions.blender.org/api/v1/swagger
|
||||
"""
|
||||
temp_path = repo_root / asset.name
|
||||
|
||||
response = requests.get(asset.browser_download_url)
|
||||
response.raise_for_status()
|
||||
temp_path.write_bytes(response.content)
|
||||
|
||||
url = f"https://extensions.blender.org/api/v1/extensions/{EXTENSION_ID}/versions/upload/"
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
files = {"version_file": temp_path.read_bytes()}
|
||||
response = requests.post(url, headers=headers, files=files)
|
||||
response.raise_for_status()
|
||||
|
||||
temp_path.unlink()
|
||||
|
||||
print(f"✓ Published {asset.name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.getenv("BLENDER_EXTENSIONS_TOKEN")
|
||||
if not token:
|
||||
raise Exception("BLENDER_EXTENSIONS_TOKEN environment variable not set")
|
||||
|
||||
# Get the repository root
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
|
||||
# Read VERSION file
|
||||
version_file = repo_root / "VERSION"
|
||||
version = version_file.read_text().strip()
|
||||
|
||||
print(f"Current VERSION: {version}")
|
||||
|
||||
tag_name = f"bonsai-{version}"
|
||||
|
||||
# Get release from GitHub
|
||||
gh = Github()
|
||||
gh_repo = gh.get_repo("IfcOpenShell/IfcOpenShell")
|
||||
release = gh_repo.get_release(tag_name)
|
||||
|
||||
assets = release.get_assets()
|
||||
|
||||
asset_platform_map: dict[str, tuple[GitReleaseAsset, str]] = {}
|
||||
for asset in assets:
|
||||
if CURRENT_PYTHON_VERSION not in asset.name:
|
||||
continue
|
||||
for platform in CURRENT_PLATFORMS:
|
||||
if platform in asset.name:
|
||||
asset_platform_map[asset.name] = (asset, platform)
|
||||
break
|
||||
|
||||
if len(asset_platform_map) != len(CURRENT_PLATFORMS):
|
||||
found_platforms = {platform for _, (_, platform) in asset_platform_map.items()}
|
||||
missing_platforms = set(CURRENT_PLATFORMS) - found_platforms
|
||||
raise Exception(
|
||||
f"Expected {len(CURRENT_PLATFORMS)} assets but found {len(asset_platform_map)}. "
|
||||
f"Missing: {', '.join(sorted(missing_platforms))}"
|
||||
)
|
||||
|
||||
print("\nRelease assets:")
|
||||
for asset_name in sorted(asset_platform_map.keys()):
|
||||
print(f"- {asset_name}")
|
||||
|
||||
# https://extensions.blender.org/api/v1/swagger
|
||||
print("\nPublishing assets to Blender Extensions:")
|
||||
for asset_name, (asset, platform) in asset_platform_map.items():
|
||||
publish_asset(asset, token, repo_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
python ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: mac-${{ matrix.arch }}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python ../IfcOpenShell/nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -9,13 +9,6 @@ jobs:
|
||||
container: rockylinux:9
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -24,6 +17,7 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -51,10 +45,10 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
@@ -62,7 +56,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -77,7 +71,7 @@ jobs:
|
||||
- name: Pack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py pack
|
||||
python3 ../nix/cache_dependencies.py pack
|
||||
|
||||
- name: Commit and Push Changes to Build Repository
|
||||
run: |
|
||||
|
||||
@@ -9,13 +9,6 @@ jobs:
|
||||
container: arm64v8/rockylinux:9
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -24,6 +17,7 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -51,10 +45,10 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}-rockylinux9
|
||||
|
||||
@@ -62,7 +56,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -o pipefail
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release uv run ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
CXXFLAGS="-O3" CFLAGS="-O3 ${DARWIN_C_SOURCE}" ADD_COMMIT_SHA=1 BUILD_CFG=Release python3 ./nix/build-all.py -v --diskcleanup 2>&1 | tee build.log
|
||||
|
||||
- name: Upload Build Logs
|
||||
if: always()
|
||||
@@ -77,7 +71,7 @@ jobs:
|
||||
- name: Pack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py pack
|
||||
python3 ../nix/cache_dependencies.py pack
|
||||
|
||||
- name: Commit and Push Changes to Build Repository
|
||||
run: |
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: win-${{ matrix.arch }}
|
||||
# Windows ccache needs ~1GB
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
|
||||
-
|
||||
name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
|
||||
-
|
||||
name: Build ifcopenshell
|
||||
|
||||
@@ -95,7 +95,8 @@ jobs:
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
}
|
||||
|
||||
run_check poe ruff
|
||||
run_check poe ruff-main
|
||||
run_check poe ruff-old
|
||||
|
||||
exit $ERROR
|
||||
continue-on-error: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
libhdf5-dev libcgal-dev libeigen3-dev
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.23
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
with:
|
||||
key: ubuntu-22.04-${{ runner.arch }}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Bonsai Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- run: uv run .github/scripts/publish-bonsai-releases.py
|
||||
env:
|
||||
BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}
|
||||
+7
-3
@@ -1,6 +1,4 @@
|
||||
#!/usr/bin/python
|
||||
# /// script
|
||||
# ///
|
||||
###############################################################################
|
||||
# #
|
||||
# This file is part of IfcOpenShell. #
|
||||
@@ -128,7 +126,13 @@ from collections.abc import Generator, Sequence
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
from typing import Literal, Union
|
||||
try:
|
||||
from typing import Literal, Union
|
||||
except:
|
||||
# python 3.6 compatibility for rocky 8
|
||||
from typing import Union
|
||||
|
||||
from typing_extensions import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# /// script
|
||||
# ///
|
||||
"""
|
||||
Cache built dependencies for builds.
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
#!/usr/bin/bash
|
||||
set -ex
|
||||
|
||||
PYODIDE_VERSION=0.29.3
|
||||
PYODIDE_BUILD_VERSION=0.33.0
|
||||
PYODIDE_XBUILDENV_ROOT="${HOME}/.cache/.pyodide-xbuildenv-${PYODIDE_BUILD_VERSION}"
|
||||
PYODIDE_XBUILDENV="${PYODIDE_XBUILDENV_ROOT}/${PYODIDE_VERSION}"
|
||||
|
||||
# Script is assuming that it will be possible to execute it multiple times
|
||||
# therefore we're clearing venv each time and ignoring existing 'emsdk' folder.
|
||||
|
||||
@@ -16,15 +11,14 @@ source .venv/bin/activate
|
||||
|
||||
# Install pyodide cross build environment.
|
||||
# Instructions: https://pyodide.org/en/stable/development/building-packages.html
|
||||
uv pip install "pyodide-build==${PYODIDE_BUILD_VERSION}"
|
||||
uv pip install pyodide-build
|
||||
# `uv run` is required, so xbuildenv would skip using `pip`.
|
||||
uv run pyodide xbuildenv install "${PYODIDE_VERSION}"
|
||||
uv run pyodide xbuildenv install
|
||||
uv run pyodide xbuildenv install-emscripten
|
||||
|
||||
EMSDK_ROOT="${PYODIDE_XBUILDENV}/emsdk"
|
||||
source "${EMSDK_ROOT}/emsdk_env.sh"
|
||||
EMSDK_ROOT=$(pyodide config get emscripten_dir)
|
||||
source ${EMSDK_ROOT}/emsdk_env.sh
|
||||
which emcc
|
||||
emcc --version
|
||||
|
||||
mkdir -p packages/ifcopenshell
|
||||
VERSION=`cat IfcOpenShell/VERSION`
|
||||
|
||||
+7
-4
@@ -3,9 +3,9 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.12",
|
||||
"ruff==0.15.9",
|
||||
"poethepoet",
|
||||
"ty==0.0.32",
|
||||
"ty==0.0.29",
|
||||
"gersemi==0.26.1",
|
||||
]
|
||||
|
||||
@@ -215,7 +215,10 @@ exclude = [
|
||||
|
||||
[tool.poe.tasks]
|
||||
|
||||
ruff = "ruff check"
|
||||
ruff-main = "ruff check --extend-exclude nix/build-all.py"
|
||||
# It's actually Python 3.6, but ruff only supports 3.7+, but it should do.
|
||||
ruff-old = "ruff check nix/build-all.py --target-version py37"
|
||||
ruff.sequence = ["ruff-main", "ruff-old"]
|
||||
|
||||
black = "black ."
|
||||
|
||||
@@ -235,7 +238,7 @@ ty-venv-ios.sequence = [
|
||||
{cmd = "uv pip install -r src/ifcopenshell-python/type-check-requirements.txt --python=src/ifcopenshell-python/.venv"},
|
||||
]
|
||||
|
||||
format.sequence = ["black", "ruff"]
|
||||
format.sequence = ["black", "ruff-main", "ruff-old"]
|
||||
|
||||
cmake-format = "gersemi . --in-place"
|
||||
|
||||
|
||||
+2
-2
@@ -17,8 +17,8 @@
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
SHELL := sh
|
||||
PYTHON:=python3
|
||||
PIP:=pip3
|
||||
PYTHON:=python3.11
|
||||
PIP:=pip3.11
|
||||
PATCH:=patch
|
||||
SED:=sed -i
|
||||
VENV_ACTIVATE:=bin/activate
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
Copyright (c) 2011-2012, Nikita Volchenkov (<nikitavolchenkov@gmail.com>),
|
||||
with Reserved Font Name OpenGost Type B.
|
||||
|
||||
Copyright (c) 2012, Valek Filippov (<frob@gnome.org>).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -28,7 +28,7 @@ DATA;
|
||||
#21=IFCSIMPLEPROPERTYTEMPLATE('1UDakJ5_f7kBhggNSW4$h5',$,'SymbolsPath','Default symbols SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#22=IFCSIMPLEPROPERTYTEMPLATE('0d53LEtgLDQxnv__NfgH7i',$,'PatternsPath','Default patterns SVG',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#23=IFCSIMPLEPROPERTYTEMPLATE('26qFNMv7nCHgU6Jd7Anga5',$,'ShadingStylesPath','Default shading styles',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#35,#36,#27,#28,#30,#34));
|
||||
#24=IFCPROPERTYSETTEMPLATE('0I9merLinF5Ap$aZwaclgm',$,'BBIM_Dimension','',.PSET_TYPEDRIVENOVERRIDE.,'IfcAnnotation/DIMENSION,IfcAnnotation/RADIUS,IfcAnnotation/DIAMETER,IfcTypeProduct',(#25,#26,#27,#28,#30));
|
||||
#25=IFCSIMPLEPROPERTYTEMPLATE('1rL2AbQsXD8RbpoWH5pYOV',$,'ShowDescriptionOnly','Hide the measurement values and show only annotation description',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#26=IFCSIMPLEPROPERTYTEMPLATE('0SVyOfB0rC2xNfdRYf3XvY',$,'SuppressZeroInches','Suppress 0 inch values in dimension annotation text (for example: 12'' - 0" -> 12'')',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#27=IFCSIMPLEPROPERTYTEMPLATE('2bUmj458PBqPAtUoI3MXsb',$,'TextPrefix','Text to add before annotation measurement value',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
@@ -37,9 +37,6 @@ DATA;
|
||||
#30=IFCSIMPLEPROPERTYTEMPLATE('2TJn72t_v2cvBUG916Dpev',$,'CustomUnit','Dimension''s custom unit',.P_ENUMERATEDVALUE.,'IfcText',$,#31,$,$,$,.READWRITE.);
|
||||
#31=IFCPROPERTYENUMERATION('CustomUnit',(IFCTEXT('Feet and Inches - Fractional'),IFCTEXT('Feet - Decimal'),IFCTEXT('Inches - Fractional'),IFCTEXT('Inches - Decimal'),IFCTEXT('Meters'),IFCTEXT('Decimeters'),IFCTEXT('Centimeters'),IFCTEXT('Millimeters')),$);
|
||||
#32=IFCSIMPLEPROPERTYTEMPLATE('0gjJzDYBX8P85qn1xcAOOo',$,'Reverse_List','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#34=IFCSIMPLEPROPERTYTEMPLATE('1Kx4Pm9nR8vBwZqTs2uYeL',$,'Separator','Characters placed between multiple dimension values when CustomUnit has more than one unit selected (default: '' / '')',.P_SINGLEVALUE.,'IfcLabel',$,$,$,$,$,.READWRITE.);
|
||||
#35=IFCSIMPLEPROPERTYTEMPLATE('3Nf6Qs1mT0pWxBuCvDyEzA',$,'SuppressZeroFeet','Suppress 0 feet in dimension annotation text (for example: 0'' - 3 1/2" -> 3 1/2")',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#36=IFCSIMPLEPROPERTYTEMPLATE('2Rg7Hn5jK4mLpNqOsVwXtY',$,'IsOrdinate','Show accumulated distance from the first vertex instead of individual segment lengths',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.);
|
||||
#33=IFCSIMPLEPROPERTYTEMPLATE('22TrcxF8jFNB4buSmzjGEF',$,'List_Separator','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.);
|
||||
ENDSEC;
|
||||
END-ISO-10303-21;
|
||||
|
||||
@@ -799,24 +799,19 @@ class DecoratorData:
|
||||
pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Dimension") or {}
|
||||
show_description_only = pset_data.get("ShowDescriptionOnly", False)
|
||||
suppress_zero_inches = pset_data.get("SuppressZeroInches", False)
|
||||
suppress_zero_feet = pset_data.get("SuppressZeroFeet", False)
|
||||
is_ordinate = pset_data.get("IsOrdinate", False)
|
||||
text_prefix = pset_data.get("TextPrefix", None) or ""
|
||||
text_suffix = pset_data.get("TextSuffix", None) or ""
|
||||
custom_units = list(pset_data.get("CustomUnit", None) or [])
|
||||
separator = pset_data.get("Separator", None) or " / "
|
||||
custom_unit_list = pset_data.get("CustomUnit", None) or ""
|
||||
custom_unit = custom_unit_list[0] if custom_unit_list else ""
|
||||
|
||||
return {
|
||||
"dimension_style": dimension_style,
|
||||
"show_description_only": show_description_only,
|
||||
"suppress_zero_inches": suppress_zero_inches,
|
||||
"suppress_zero_feet": suppress_zero_feet,
|
||||
"is_ordinate": is_ordinate,
|
||||
"text_prefix": text_prefix,
|
||||
"text_suffix": text_suffix,
|
||||
"fill_bg": fill_bg,
|
||||
"custom_units": custom_units,
|
||||
"separator": separator,
|
||||
"custom_unit": custom_unit,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -490,7 +490,7 @@ class BaseDecorator:
|
||||
self.draw_label(context, text=text, line_no=line_number_start, multiline=True, **draw_label_kwargs)
|
||||
|
||||
@cache
|
||||
def format_value(self, context, value, suppress_zero_inches=False, suppress_zero_feet=False, custom_unit=None, in_unit_length=False):
|
||||
def format_value(self, context, value, suppress_zero_inches=False, custom_unit=None, in_unit_length=False):
|
||||
drawing_pset_data = DrawingsData.data["active_drawing_pset_data"]
|
||||
precision = drawing_pset_data.get("MetricPrecision", None)
|
||||
if not precision:
|
||||
@@ -502,7 +502,6 @@ class BaseDecorator:
|
||||
precision=precision,
|
||||
decimal_places=decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=custom_unit,
|
||||
in_unit_length=in_unit_length,
|
||||
)
|
||||
@@ -719,13 +718,11 @@ class DimensionDecorator(BaseDecorator):
|
||||
if not dimension_data:
|
||||
return
|
||||
show_description_only = dimension_data["show_description_only"]
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
text_prefix = dimension_data["text_prefix"]
|
||||
text_suffix = dimension_data["text_suffix"]
|
||||
viewportDrawingScale = self.get_viewport_drawing_scale(context)
|
||||
text_offset_value = viewportDrawingScale * 3
|
||||
|
||||
ordinate_total = 0.0
|
||||
for i0, i1 in indices:
|
||||
v0 = Vector(vertices[i0])
|
||||
v1 = Vector(vertices[i1])
|
||||
@@ -744,25 +741,16 @@ class DimensionDecorator(BaseDecorator):
|
||||
"multiline": True,
|
||||
"text_dir": text_dir,
|
||||
}
|
||||
base_pos = p1 if is_ordinate else p0 + text_dir * 0.5
|
||||
base_pos = p0 + text_dir * 0.5
|
||||
|
||||
if not show_description_only:
|
||||
segment_length = (v1 - v0).length
|
||||
if is_ordinate:
|
||||
ordinate_total += segment_length
|
||||
length = ordinate_total if is_ordinate else segment_length
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = dimension_data["separator"].join(str(p) for p in parts)
|
||||
length = (v1 - v0).length
|
||||
text = self.format_value(
|
||||
context,
|
||||
length,
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
if isinstance(self, DiameterDecorator):
|
||||
text = "D" + text
|
||||
text = text_prefix + text + text_suffix
|
||||
@@ -773,18 +761,15 @@ class DimensionDecorator(BaseDecorator):
|
||||
|
||||
self.draw_label(
|
||||
text=text,
|
||||
pos=base_pos + text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if is_ordinate else "bottom-middle",
|
||||
pos=base_pos + text_offset,
|
||||
box_alignment="bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**common_label_attrs,
|
||||
)
|
||||
|
||||
if not show_description_only and description:
|
||||
self.draw_label(
|
||||
text=description,
|
||||
pos=base_pos - text_offset + (Vector((0, text_offset_value)) if is_ordinate else Vector((0, 0))),
|
||||
box_alignment="top-right" if is_ordinate else "top-middle",
|
||||
**common_label_attrs,
|
||||
text=description, pos=base_pos - text_offset, box_alignment="top-middle", **common_label_attrs
|
||||
)
|
||||
|
||||
|
||||
@@ -980,9 +965,7 @@ class RadiusDecorator(BaseDecorator):
|
||||
|
||||
def get_text():
|
||||
length = (spline_points[-1] - spline_points[-2]).length
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [self.format_value(context, length, suppress_zero_feet=dimension_data["suppress_zero_feet"], custom_unit=unit) for unit in units_to_format]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
return "R" + self.format_value(context, length, custom_unit=dimension_data["custom_unit"])
|
||||
|
||||
self.draw_dimension_text(
|
||||
context, get_text, description, dimension_data, pos=pos, text_dir=Vector((1, 0)), box_alignment="center"
|
||||
|
||||
@@ -170,7 +170,6 @@ def format_distance(
|
||||
precision=None,
|
||||
decimal_places=None,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
in_unit_length=False,
|
||||
custom_unit=None,
|
||||
):
|
||||
@@ -311,10 +310,10 @@ def format_distance(
|
||||
tx_dist = ""
|
||||
if feet:
|
||||
tx_dist += str(feet) + "'"
|
||||
if not feet and not add_inches and not suppress_zero_feet:
|
||||
if not feet and not add_inches:
|
||||
tx_dist += str(feet) + "'"
|
||||
|
||||
if not feet and add_inches and unit_length != "INCHES" and not suppress_zero_feet:
|
||||
if not feet and add_inches:
|
||||
if value < 0:
|
||||
tx_dist += "-0' - "
|
||||
else:
|
||||
|
||||
@@ -1371,18 +1371,14 @@ class SvgWriter:
|
||||
|
||||
def get_text():
|
||||
radius = (points[-1].co - points[-2].co).length
|
||||
units_to_format = dimension_data["custom_units"] if dimension_data["custom_units"] else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
return "R" + dimension_data["separator"].join(str(p) for p in parts)
|
||||
radius = helper.format_distance(
|
||||
radius,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
text = f"R{radius}"
|
||||
return text
|
||||
|
||||
self.draw_dimension_text(
|
||||
get_text, tag, dimension_data, text_position=text_position, class_str="RADIUS", box_alignment="center"
|
||||
@@ -1507,12 +1503,10 @@ class SvgWriter:
|
||||
text_format=lambda x: "D" + x,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
|
||||
def draw_dimension_annotations(self, obj: bpy.types.Object) -> None:
|
||||
@@ -1523,15 +1517,11 @@ class SvgWriter:
|
||||
dimension_data = DecoratorData.get_dimension_data(obj)
|
||||
|
||||
assert isinstance(obj.data, bpy.types.Curve)
|
||||
is_ordinate = dimension_data["is_ordinate"]
|
||||
for spline in obj.data.splines:
|
||||
points = self.get_spline_points(spline)
|
||||
ordinate_total = 0.0
|
||||
for i in range(len(points) - 1):
|
||||
v0_global = matrix_world @ points[i].co.xyz
|
||||
v1_global = matrix_world @ points[i + 1].co.xyz
|
||||
if is_ordinate:
|
||||
ordinate_total += (v1_global - v0_global).length
|
||||
self.draw_dimension_annotation(
|
||||
v0_global,
|
||||
v1_global,
|
||||
@@ -1539,13 +1529,10 @@ class SvgWriter:
|
||||
dimension_text=dimension_text,
|
||||
show_description_only=dimension_data["show_description_only"],
|
||||
suppress_zero_inches=dimension_data["suppress_zero_inches"],
|
||||
suppress_zero_feet=dimension_data["suppress_zero_feet"],
|
||||
text_prefix=dimension_data["text_prefix"],
|
||||
text_suffix=dimension_data["text_suffix"],
|
||||
fill_bg=dimension_data["fill_bg"],
|
||||
custom_units=dimension_data["custom_units"],
|
||||
separator=dimension_data["separator"],
|
||||
distance_override=ordinate_total if is_ordinate else None,
|
||||
custom_unit=dimension_data["custom_unit"],
|
||||
)
|
||||
|
||||
def draw_measureit_arch_dimension_annotations(self) -> None:
|
||||
@@ -1569,13 +1556,10 @@ class SvgWriter:
|
||||
text_format=lambda x: x,
|
||||
show_description_only=False,
|
||||
suppress_zero_inches=False,
|
||||
suppress_zero_feet=False,
|
||||
text_prefix="",
|
||||
text_suffix="",
|
||||
fill_bg=False,
|
||||
custom_units=None,
|
||||
separator=" / ",
|
||||
distance_override=None,
|
||||
custom_unit=None,
|
||||
) -> None:
|
||||
offset = Vector([self.raw_width, self.raw_height]) / 2
|
||||
v0 = self.project_point_onto_camera(v0_global)
|
||||
@@ -1588,10 +1572,7 @@ class SvgWriter:
|
||||
sheet_dimension = (end - start).length
|
||||
|
||||
# if annotation can't fit offset text to the right of marker
|
||||
if distance_override is not None:
|
||||
text_position = end
|
||||
else:
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
text_position = mid if sheet_dimension > 5 else (end + (3 * vector.normalized()))
|
||||
angle = math.degrees(vector.angle_signed(Vector((1, 0))))
|
||||
|
||||
line = self.svg.line(start=start, end=end, class_=" ".join(classes))
|
||||
@@ -1606,20 +1587,15 @@ class SvgWriter:
|
||||
}
|
||||
|
||||
if not show_description_only:
|
||||
dimension = distance_override if distance_override is not None else (v1_global - v0_global).length
|
||||
units_to_format = custom_units if custom_units else [None]
|
||||
parts = [
|
||||
helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
suppress_zero_feet=suppress_zero_feet,
|
||||
custom_unit=unit,
|
||||
)
|
||||
for unit in units_to_format
|
||||
]
|
||||
text = text_prefix + separator.join(str(p) for p in parts) + text_suffix
|
||||
dimension = (v1_global - v0_global).length
|
||||
dimension = helper.format_distance(
|
||||
dimension,
|
||||
precision=self.precision,
|
||||
decimal_places=self.decimal_places,
|
||||
suppress_zero_inches=suppress_zero_inches,
|
||||
custom_unit=custom_unit,
|
||||
)
|
||||
text = text_prefix + str(dimension) + text_suffix
|
||||
else:
|
||||
if not dimension_text:
|
||||
return
|
||||
@@ -1627,8 +1603,8 @@ class SvgWriter:
|
||||
|
||||
text_tags += self.create_text_tag(
|
||||
text,
|
||||
text_position + perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="bottom-right" if distance_override is not None else "bottom-middle",
|
||||
text_position + perpendicular,
|
||||
box_alignment="bottom-middle",
|
||||
multiline_to_bottom=False,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
@@ -1636,8 +1612,8 @@ class SvgWriter:
|
||||
if not show_description_only and dimension_text:
|
||||
text_tags += self.create_text_tag(
|
||||
dimension_text,
|
||||
text_position - perpendicular + (Vector((0, 1.5)) if distance_override is not None else Vector((0, 0))),
|
||||
box_alignment="top-right" if distance_override is not None else "top-middle",
|
||||
text_position - perpendicular,
|
||||
box_alignment="top-middle",
|
||||
multiline_to_bottom=True,
|
||||
**text_tag_kwargs,
|
||||
)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"})
|
||||
|
||||
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
|
||||
@@ -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]]]] = []
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
|
||||
@@ -630,7 +630,7 @@ class EditAssignedMaterial(bpy.types.Operator, tool.Ifc.Operator):
|
||||
slab.DumbSlabPlaner().regenerate_from_layer_set(layer_set)
|
||||
|
||||
if material_set_usage.is_a("IfcMaterialProfileSetUsage"):
|
||||
if "CardinalPoint" in attributes and attributes["CardinalPoint"] is not None:
|
||||
if "CardinalPoint" in attributes:
|
||||
attributes["CardinalPoint"] = int(attributes["CardinalPoint"])
|
||||
ifcopenshell.api.material.edit_profile_usage(
|
||||
self.file,
|
||||
|
||||
@@ -468,16 +468,14 @@ class ChangeExtrusionXAngle(bpy.types.Operator, tool.Ifc.Operator):
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, 0, tolerance=0.001) else existing_x_angle
|
||||
existing_x_angle = 0 if tool.Cad.is_x(existing_x_angle, pi, tolerance=0.001) else existing_x_angle
|
||||
|
||||
profiles = extrusion.SweptArea.Profiles if extrusion.SweptArea.is_a("IfcCompositeProfileDef") else [extrusion.SweptArea]
|
||||
for profile in profiles:
|
||||
coord_list = builder.get_polyline_coords(profile.OuterCurve)
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
|
||||
] # Reset the transformation and returns to the original points with 0 degrees
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
|
||||
] # Apply the transformation for the new x_angle
|
||||
builder.set_polyline_coords(profile.OuterCurve, coord_list)
|
||||
coord_list = builder.get_polyline_coords(extrusion.SweptArea.OuterCurve)
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(cos(existing_x_angle))) for p in coord_list
|
||||
] # Reset the transformation and returns to the original points with 0 degrees
|
||||
coord_list = [
|
||||
(p[0], p[1] * abs(1 / cos(x_angle))) for p in coord_list
|
||||
] # Apply the transformation for the new x_angle
|
||||
builder.set_polyline_coords(extrusion.SweptArea.OuterCurve, coord_list)
|
||||
|
||||
# The extrusion direction calculated previously default to the positive direction
|
||||
# Here we set the extrusion direction to negative if that's the case
|
||||
|
||||
@@ -118,19 +118,6 @@ def update_shader_graph(self: Union["Texture", "BIMStylesProperties"], context:
|
||||
tool.Loader.create_surface_style_with_textures(material, shading_data, textures_data)
|
||||
|
||||
|
||||
def _make_clear_null_updater(null_prop: str):
|
||||
def _update(self: "BIMStylesProperties", context: bpy.types.Context) -> None:
|
||||
self[null_prop] = False
|
||||
update_shader_graph(self, context)
|
||||
|
||||
return _update
|
||||
|
||||
|
||||
update_diffuse_colour = _make_clear_null_updater("is_diffuse_colour_null")
|
||||
update_specular_colour = _make_clear_null_updater("is_specular_colour_null")
|
||||
update_specular_highlight_value = _make_clear_null_updater("is_specular_highlight_null")
|
||||
|
||||
|
||||
UV_MODES = [
|
||||
("UV", "UV", _("Actual UV data presented on the geometry")),
|
||||
("Generated", "Generated", _("Automatically-generated UV from the vertex positions of the mesh")),
|
||||
@@ -234,29 +221,24 @@ class BIMStylesProperties(PropertyGroup):
|
||||
transparency: bpy.props.FloatProperty(
|
||||
name="Transparency", default=0.0, min=0.0, max=1.0, update=update_shader_graph
|
||||
)
|
||||
is_diffuse_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
# TODO: do something on null?
|
||||
is_diffuse_colour_null: BoolProperty(name="Is Null")
|
||||
diffuse_colour_class: EnumProperty(
|
||||
items=[(x, x, "") for x in get_args(ColourClass)],
|
||||
name="Diffuse Colour Class",
|
||||
update=update_diffuse_colour,
|
||||
update=update_shader_graph,
|
||||
)
|
||||
diffuse_colour: bpy.props.FloatVectorProperty(
|
||||
name="Diffuse Colour",
|
||||
subtype="COLOR",
|
||||
default=(1, 1, 1),
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3,
|
||||
update=update_diffuse_colour,
|
||||
name="Diffuse Colour", subtype="COLOR", default=(1, 1, 1), min=0.0, max=1.0, size=3, update=update_shader_graph
|
||||
)
|
||||
diffuse_colour_ratio: bpy.props.FloatProperty(
|
||||
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_diffuse_colour
|
||||
name="Diffuse Ratio", default=0.0, min=0.0, max=1.0, update=update_shader_graph
|
||||
)
|
||||
is_specular_colour_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
is_specular_colour_null: BoolProperty(name="Is Null")
|
||||
specular_colour_class: EnumProperty(
|
||||
items=[(x, x, "") for x in get_args(ColourClass)],
|
||||
name="Specular Colour Class",
|
||||
update=update_specular_colour,
|
||||
update=update_shader_graph,
|
||||
default="IfcNormalisedRatioMeasure",
|
||||
)
|
||||
specular_colour: bpy.props.FloatVectorProperty(
|
||||
@@ -266,7 +248,7 @@ class BIMStylesProperties(PropertyGroup):
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
size=3,
|
||||
update=update_specular_colour,
|
||||
update=update_shader_graph,
|
||||
)
|
||||
specular_colour_ratio: bpy.props.FloatProperty(
|
||||
name="Specular Ratio",
|
||||
@@ -274,16 +256,16 @@ class BIMStylesProperties(PropertyGroup):
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
update=update_specular_colour,
|
||||
update=update_shader_graph,
|
||||
)
|
||||
is_specular_highlight_null: BoolProperty(name="Is Null", update=update_shader_graph)
|
||||
is_specular_highlight_null: BoolProperty(name="Is Null")
|
||||
specular_highlight: bpy.props.FloatProperty(
|
||||
name="Specular Highlight",
|
||||
description="Used as Roughness value in PHYSICAL Reflectance Method",
|
||||
default=0.0,
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
update=update_specular_highlight_value,
|
||||
update=update_shader_graph,
|
||||
)
|
||||
reflectance_method: EnumProperty(
|
||||
name="Reflectance Method",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -987,8 +987,7 @@ class Cost(bonsai.core.tool.Cost):
|
||||
def disable_editing_cost_item_parent(cls) -> None:
|
||||
props = cls.get_cost_props()
|
||||
props.active_cost_item_id = 0
|
||||
if props.change_cost_item_parent == True:
|
||||
props.change_cost_item_parent = False
|
||||
props.change_cost_item_parent = False
|
||||
|
||||
@classmethod
|
||||
def load_cost_item_quantities(cls, cost_item: Optional[ifcopenshell.entity_instance] = None) -> None:
|
||||
|
||||
@@ -1756,10 +1756,6 @@ class Drawing(bonsai.core.tool.Drawing):
|
||||
# For section/elevation views, elevate the segment vertically
|
||||
if not (points := helper.elevate_segment(bounds, [v1, v2])):
|
||||
return
|
||||
elif target_view == "MODEL_VIEW":
|
||||
# For model views, clip to XY bounds and keep Z (3D line at true elevation)
|
||||
if not (points := helper.clip_segment(bounds, [v1, v2])):
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
@@ -203,11 +203,6 @@ class Style(bonsai.core.tool.Style):
|
||||
|
||||
available_props = props.bl_rna.properties.keys()
|
||||
for prop_blender, prop_ifc in STYLE_PROPS_MAP.items():
|
||||
null_prop_name = f"is_{prop_blender}_null"
|
||||
if null_prop_name in available_props and getattr(props, null_prop_name):
|
||||
surface_style_data[prop_ifc] = None
|
||||
continue
|
||||
|
||||
class_prop_name = f"{prop_blender}_class"
|
||||
|
||||
# get detailed color properties if available
|
||||
|
||||
@@ -61,8 +61,6 @@ When Blender ships with a new Python version:
|
||||
- What to update
|
||||
* - ``.github/workflows/ci-lint.yaml``
|
||||
- ``MIN_BLENDER_PY_VERSION``
|
||||
* - ``.github/scripts/publish-bonsai-releases.py``
|
||||
- ``CURRENT_PYTHON_VERSION``
|
||||
* - ``src/bonsai/Makefile``
|
||||
- ``SUPPORTED_PYVERSIONS``
|
||||
* - ``src/bonsai/scripts/dev_environment.py``
|
||||
@@ -75,18 +73,6 @@ Notes:
|
||||
|
||||
- Typically all packages are released at once using the same version schema
|
||||
- The ``README.md`` badges can serve as a visual reference for what versions have been released
|
||||
- Corrective Release (if needed after a standard release):
|
||||
|
||||
- Create a new branch from the release tag (e.g., from the ``ifcopenshell-0.8.5`` tag)
|
||||
- Update ``VERSION`` with the ``-post1`` suffix (e.g., ``0.8.5-post1``, **not** ``.post1``)
|
||||
- The hyphen is required for semantic versioning compliance; Blender will not process ``.post1`` suffixes correctly
|
||||
- Follow the standard release process for the corrective version
|
||||
|
||||
- Multiple Blender Python Versions:
|
||||
|
||||
- Blender does not allow multiple builds for the same platform with different Python versions (e.g., cannot have both ``bonsai_py311-0.8.5-windows-x64.zip`` and ``bonsai_py313-0.8.5-windows-x64.zip``)
|
||||
- Workaround: publish different Python versions as different extension versions (e.g., py313 as ``0.8.5`` and py311 as ``0.8.5-post1``)
|
||||
- Set the maximum Blender version on the Blender extensions platform UI to prevent conflicts (e.g., set max version ``5.1.0`` for ``0.8.5-post1``, which restricts it to versions below 5.1.0)
|
||||
|
||||
Things to update:
|
||||
|
||||
@@ -110,13 +96,10 @@ Things to update:
|
||||
- ``.github/workflows/ci-ifcsverchok.yml`` - release ifcsverchok Blender add-on in GitHub releases
|
||||
- ``.github/workflows/ci-ifctester-pypi.yml`` - release `ifctester <https://pypi.org/project/ifctester/>`_ to PyPI
|
||||
- ``.github/workflows/ci-pyodide-wasm-release.yml`` - release pyodide wasm wheel to `wasm-wheels <https://github.com/IfcOpenShell/wasm-wheels>`_
|
||||
- ``.github/workflows/publish-bonsai-releases.yml`` - publish Bonsai Blender extension to `Blender extensions platform <https://extensions.blender.org/add-ons/bonsai/>`_
|
||||
|
||||
- ❗ Requires ``BLENDER_EXTENSIONS_TOKEN`` secret to be set - ❗ not yet configured
|
||||
|
||||
- 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)
|
||||
- `publish-websites.yml` - publishes `bonsaibim.org <https://bonsaibim.org>`_ (`bonsaibim_org_static_html <https://github.com/IfcOpenShell/bonsaibim_org_static_html>`_ repo) and `ifcopenshell.org <https://ifcopenshell.org>`_ (`ifcopenshell_org_static_html <https://github.com/IfcOpenShell/ifcopenshell_org_static_html>`_ repo)
|
||||
- `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
|
||||
|
||||
@@ -58,7 +58,7 @@ Fields
|
||||
Class** based on the IFC Schema version.
|
||||
|
||||
**Unit System**
|
||||
Choose between metric and imperial units of measurement when creating a project. Project data is stored in this Unit System and displayed according to e.g. Length Unit, Area Unit, Volume Unit. Properly changing the Unit System after project creation requires conversion. See `Blender Manual : Scene Properties : Units <https://docs.blender.org/manual/en/latest/scene_layout/scene/properties.html#units>`_ for a description of changing the display units e.g. from Feet to Adaptive (enable Separate Units option) for Feet-and-Inches.
|
||||
Choose between metric and imperial units of measurement when creating a project.
|
||||
|
||||
**Length Unit**
|
||||
Depending on the unit system, choose the default unit to be used for all length measurements. Lengths are used for moving objects around in the 3D scene, as well as lengths, widths, height, and depth quantity take-off data.
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# Bonsai - OpenBIM Blender Add-on
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of Bonsai.
|
||||
#
|
||||
# Bonsai is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Bonsai is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import test.bim.bootstrap
|
||||
import ifcopenshell.api.cost
|
||||
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
import test.bim.bootstrap
|
||||
from test.bim.bootstrap import NewFile
|
||||
|
||||
from bonsai.tool.cost import Cost as subject
|
||||
|
||||
class TestImplementsTool(NewFile):
|
||||
def test_run(self):
|
||||
assert isinstance(subject(), bonsai.core.tool.Cost)
|
||||
|
||||
class TestDisableEditingCostItemParent(NewFile):
|
||||
def test_avoid_recursion_error(newfile, monkeypatch):
|
||||
class DummyProps:
|
||||
def __init__(self):
|
||||
self.change_cost_item_parent = None
|
||||
self.active_cost_item_id = 5
|
||||
|
||||
props = DummyProps()
|
||||
monkeypatch.setattr(
|
||||
"bonsai.tool.Cost.get_cost_props",
|
||||
lambda: props
|
||||
)
|
||||
subject.disable_editing_cost_item_parent()
|
||||
assert props.active_cost_item_id == 0
|
||||
assert props.change_cost_item_parent is not False
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
SHELL := sh
|
||||
IS_STABLE:=FALSE
|
||||
PYTHON:=python3
|
||||
PIP:=pip3
|
||||
PYTHON:=python3.11
|
||||
PIP:=pip3.11
|
||||
VERSION:=$(shell cat ../../VERSION)
|
||||
VERSION_DATE:=$(shell date '+%y%m%d')
|
||||
SED:=sed -i
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
"Area": "get_net_side_area",
|
||||
"Height": "get_height",
|
||||
"Perimeter": "get_rectangular_perimeter",
|
||||
"Width": "get_x"
|
||||
"Width": "get_length"
|
||||
}
|
||||
},
|
||||
"IfcDuctFitting + IfcDuctFittingType": {
|
||||
|
||||
@@ -29,7 +29,16 @@ async function ensurePyodide() {
|
||||
const micropip = pyodide.pyimport("micropip");
|
||||
micropip.install("python-dateutil")
|
||||
|
||||
const wheelUrl = "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.5-cp313-cp313-pyodide_2025_0_wasm32.whl";
|
||||
// Detect python minor version (3.12 vs 3.13) and pick a matching wheel.
|
||||
const pyVer = pyodide.runPython(`
|
||||
import sys
|
||||
f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
`);
|
||||
|
||||
const wheelUrl =
|
||||
pyVer === "3.13"
|
||||
? "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.3+34a1bc6-cp313-cp313-emscripten_4_0_9_wasm32.whl"
|
||||
: "https://ifcopenshell.github.io/wasm-wheels/ifcopenshell-0.8.2+d50e806-cp312-cp312-emscripten_3_1_58_wasm32.whl";
|
||||
|
||||
await micropip.install(wheelUrl);
|
||||
|
||||
|
||||
@@ -104,6 +104,14 @@ struct gradient_fn_evaluator : public fn_evaluator {
|
||||
auto xy = horizontal_evaluator_.evaluate(u);
|
||||
auto uz = vertical_evaluator_.evaluate(u);
|
||||
|
||||
// curvature is stored in row 3 - capture it and remove it from the xy and uz matrices
|
||||
// so the matrix operations (ie multiplication) works correct.y
|
||||
auto horizontal_curvature = xy.row(3);
|
||||
xy.row(3) = Eigen::Vector4d(0, 0, 0, 1);
|
||||
|
||||
auto vertical_curvature = uz.row(3);
|
||||
uz.row(3) = Eigen::Vector4d(0, 0, 0, 1);
|
||||
|
||||
uz(0, 3) = 0.0; // x is distance along. zero it out so it doesn't add to the x from horizontal
|
||||
uz.col(1).swap(uz.col(2)); // uz is 2D in distance along - y plane, swap y and z so elevations become z
|
||||
uz.row(1).swap(uz.row(2));
|
||||
@@ -111,6 +119,12 @@ struct gradient_fn_evaluator : public fn_evaluator {
|
||||
Eigen::Matrix4d m;
|
||||
m = xy * uz; // combine horizontal and vertical
|
||||
|
||||
// Put curvature back into the solution matrix
|
||||
// curvature for vertical is in column 0, need it to be in column 1
|
||||
// so it doesn't add to curvature for horizontal
|
||||
std::swap(vertical_curvature(0), vertical_curvature(1));
|
||||
m.row(3) = horizontal_curvature + vertical_curvature;
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -562,7 +562,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial* material) {
|
||||
}
|
||||
// Check if it's failed or just some unsupported case.
|
||||
if (failed_on_purpose_.find(styled_item) == failed_on_purpose_.end()) {
|
||||
failed_on_purpose_.insert(material);
|
||||
return nullptr;
|
||||
}
|
||||
Logger::Warning("Skipping unsupported material style for material: ", material);
|
||||
@@ -570,7 +569,6 @@ taxonomy::ptr mapping::map_impl(const IfcSchema::IfcMaterial* material) {
|
||||
}
|
||||
|
||||
// When material does not have a representation we don't create a style from it
|
||||
failed_on_purpose_.insert(material);
|
||||
return nullptr;
|
||||
|
||||
/*
|
||||
|
||||
@@ -5,8 +5,8 @@ VERSION_DATE:=$(shell date '+%y%m%d')
|
||||
PYVERSION:=py311
|
||||
PLATFORM:=linux64
|
||||
|
||||
PYTHON:=python3
|
||||
PIP:=pip3
|
||||
PYTHON:=python3.11
|
||||
PIP:=pip3.11
|
||||
SED:=sed -i
|
||||
VENV_ACTIVATE:=bin/activate
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ def remove_cost_item(file: ifcopenshell.file, cost_item: ifcopenshell.entity_ins
|
||||
if history:
|
||||
ifcopenshell.util.element.remove_deep2(file, history)
|
||||
elif inverse.is_a("IfcRelAssignsToControl"):
|
||||
if len(inverse.RelatedObjects) >= 2:
|
||||
if len(inverse.RelatedObjects) >= 2 or inverse.RelatingControl == cost_item:
|
||||
continue
|
||||
history = inverse.OwnerHistory
|
||||
file.remove(inverse)
|
||||
|
||||
@@ -42,8 +42,7 @@ WHITE = numpy.array((1.0, 1.0, 1.0))
|
||||
|
||||
DO_NOTHING = lambda *args: None
|
||||
|
||||
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, "arrange_polygon_settings") else None
|
||||
|
||||
ARRANGE_POLYGON_SETTINGS = W.arrange_polygon_settings() if hasattr(W, 'arrange_polygon_settings') else None
|
||||
|
||||
@dataclass
|
||||
class draw_settings:
|
||||
|
||||
@@ -420,15 +420,7 @@ class SchemaClass(codegen.Base):
|
||||
|
||||
if isinstance(type, nodes.AggregationType):
|
||||
aggr_type = type.aggregate_type
|
||||
|
||||
def make_bound(b):
|
||||
# `?` and non-literal bounds (attribute references, arithmetic expressions) collapse to -1.
|
||||
#
|
||||
try:
|
||||
return int(b)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
|
||||
make_bound = lambda b: -1 if b == "?" else int(b)
|
||||
bound1, bound2 = map(make_bound, (type.bounds.lower, type.bounds.upper))
|
||||
decl_type = get_declared_type(type.type, emitted_names)
|
||||
return x.aggregation_type(aggr_type, bound1, bound2, decl_type)
|
||||
@@ -555,16 +547,7 @@ class SchemaClass(codegen.Base):
|
||||
inv_attrs = []
|
||||
for attr in type.inverse:
|
||||
if attr.bounds:
|
||||
|
||||
def make_bound(b):
|
||||
# `?` and non-literal bounds (attribute references, arithmetic
|
||||
# expressions) collapse to -1 (unbounded) — the C++ runtime has
|
||||
# no third state for "dynamic cardinality".
|
||||
try:
|
||||
return int(b)
|
||||
except (TypeError, ValueError):
|
||||
return -1
|
||||
|
||||
make_bound = lambda b: -1 if b == "?" else int(b)
|
||||
bound1, bound2 = map(make_bound, (attr.bounds.lower, attr.bounds.upper))
|
||||
else:
|
||||
bound1, bound2 = -1, -1
|
||||
|
||||
Submodule src/ifcopenshell-python/ifcopenshell/simple_spf updated: 9400d243d8...ed50b756c4
@@ -196,12 +196,9 @@ def get_cost_items_for_product(product: ifcopenshell.entity_instance) -> list[if
|
||||
:return: A list of IfcCostItem objects representing the cost items related to the product.
|
||||
"""
|
||||
cost_items = []
|
||||
for assignment in product.HasAssignments or []:
|
||||
if assignment.is_a("IfcRelAssignsToControl"):
|
||||
control = assignment.RelatingControl
|
||||
if control and control.is_a("IfcCostItem"):
|
||||
cost_items.append(control)
|
||||
|
||||
for assignment in product.HasAssignments:
|
||||
if assignment.is_a("IfcRelAssignsToControl") and assignment.RelatingControl.is_a("IfcCostItem"):
|
||||
cost_items.append(assignment.RelatingControl)
|
||||
return cost_items
|
||||
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import ifcopenshell.express
|
||||
|
||||
sys.path.insert(0, os.path.dirname(ifcopenshell.express.__file__))
|
||||
|
||||
|
||||
def _parse(schema_text):
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".exp", delete=False) as f:
|
||||
f.write(schema_text)
|
||||
path = f.name
|
||||
try:
|
||||
return ifcopenshell.express.parse(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
cache = path + ".cache.dat"
|
||||
if os.path.exists(cache):
|
||||
os.unlink(cache)
|
||||
|
||||
|
||||
class TestAggregateBounds(unittest.TestCase):
|
||||
def test_literal_bounds_preserved(self):
|
||||
"""After loading [1;3] -> (1, 3)?"""
|
||||
s = _parse("SCHEMA t; ENTITY E; v : ARRAY [1:3] OF REAL; END_ENTITY; END_SCHEMA;")
|
||||
agg = (
|
||||
next(d for d in s.schema.declarations() if d.name() == "E")
|
||||
.attributes()[0]
|
||||
.type_of_attribute()
|
||||
.as_aggregation_type()
|
||||
)
|
||||
self.assertEqual((agg.bound1(), agg.bound2()), (1, 3))
|
||||
s.disown()
|
||||
|
||||
def test_unbounded_marker(self):
|
||||
"""[0:?] -> (0, -1)?"""
|
||||
s = _parse("SCHEMA t; ENTITY E; v : LIST [0:?] OF REAL; END_ENTITY; END_SCHEMA;")
|
||||
agg = (
|
||||
next(d for d in s.schema.declarations() if d.name() == "E")
|
||||
.attributes()[0]
|
||||
.type_of_attribute()
|
||||
.as_aggregation_type()
|
||||
)
|
||||
# import pdb; pdb.set_trace()
|
||||
self.assertEqual((agg.bound1(), agg.bound2()), (0, -1))
|
||||
s.disown()
|
||||
|
||||
def test_voxel_grid_with_dynamic_bound_loads(self):
|
||||
"""
|
||||
Array that is an expression : [1:dim_x*dim_y*dim_z]
|
||||
Parsing must not crash, Bbund must be (1, -1)
|
||||
"""
|
||||
s = _parse("""
|
||||
SCHEMA t;
|
||||
TYPE IfcBoolean = BOOLEAN; END_TYPE;
|
||||
|
||||
ENTITY IfcVoxelHolder;
|
||||
NumberOfVoxelsX : INTEGER;
|
||||
NumberOfVoxelsY : INTEGER;
|
||||
NumberOfVoxelsZ : INTEGER;
|
||||
Voxels : ARRAY [1:NumberOfVoxelsX*NumberOfVoxelsY*NumberOfVoxelsZ] OF IfcBoolean;
|
||||
END_ENTITY;
|
||||
END_SCHEMA;
|
||||
""")
|
||||
holder = next(d for d in s.schema.declarations() if d.name() == "IfcVoxelHolder")
|
||||
voxels = holder.attributes()[-1].type_of_attribute().as_aggregation_type()
|
||||
self.assertEqual((voxels.bound1(), voxels.bound2()), (1, -1))
|
||||
s.disown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,52 +0,0 @@
|
||||
# IfcOpenShell - IFC toolkit and geometry engine
|
||||
# Copyright (C) 2021 Dion Moult <dion@thinkmoult.com>
|
||||
#
|
||||
# This file is part of IfcOpenShell.
|
||||
#
|
||||
# IfcOpenShell is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# IfcOpenShell is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import pytest
|
||||
|
||||
import ifcopenshell.api.control
|
||||
import ifcopenshell.api.cost
|
||||
import test.bootstrap
|
||||
import ifcopenshell.api.root
|
||||
|
||||
import ifcopenshell.util.cost as subject
|
||||
|
||||
class TestGetCostItemForProduct(test.bootstrap.IFC4):
|
||||
def test_run(self):
|
||||
model = self.file
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
|
||||
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
|
||||
ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1)
|
||||
assert list(subject.get_cost_items_for_product(element)) == [item1]
|
||||
|
||||
def test_remove_cost_item(self):
|
||||
model = self.file
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
|
||||
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
|
||||
ifcopenshell.api.control.assign_control(model, related_objects=[element], relating_control=item1)
|
||||
ifcopenshell.api.cost.remove_cost_item(model, cost_item = item1)
|
||||
assert list(subject.get_cost_items_for_product(element)) == []
|
||||
|
||||
def test_no_assigned_cost_items(self):
|
||||
model = self.file
|
||||
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")
|
||||
cost_schedule = ifcopenshell.api.cost.add_cost_schedule(model)
|
||||
item1 = ifcopenshell.api.cost.add_cost_item(model, cost_schedule=cost_schedule)
|
||||
assert list(subject.get_cost_items_for_product(element)) == []
|
||||
|
||||
@@ -475,7 +475,7 @@ class IFC_PARSE_API IfcHierarchyHelper : public IfcParse::IfcFile {
|
||||
t->set_attribute_value(1, owner_hist);
|
||||
int relating_index = 4;
|
||||
int related_index = 5;
|
||||
if (T::Class().name() == "IfcRelContainedInSpatialStructure" || T::Class().name() == "IfcRelReferencedInSpatialStructure" || std::is_base_of<typename Schema::IfcRelDefines, T>::value) {
|
||||
if (T::Class().name() == "IfcRelContainedInSpatialStructure" || std::is_base_of<typename Schema::IfcRelDefines, T>::value) {
|
||||
// some classes have attributes reversed.
|
||||
std::swap(relating_index, related_index);
|
||||
}
|
||||
|
||||
@@ -432,15 +432,6 @@ class DebugWriter {
|
||||
}
|
||||
}
|
||||
|
||||
void write_point(const Point_2& p, const std::string& name) {
|
||||
if (enabled_) {
|
||||
obj << "o " << name << "\n";
|
||||
obj << "v " << CGAL::to_double(p.x()) << " " << CGAL::to_double(p.y()) << " 0\n";
|
||||
vi++;
|
||||
svg << "<circle class=\"" << name << "\" cx=\"" << CGAL::to_double(p.x()) << "\" cy=\"" << -CGAL::to_double(p.y()) << "\" r=\"0.5\" />\n";
|
||||
}
|
||||
}
|
||||
|
||||
void write_polygon(const Polygon_with_holes_2& polygon, const std::string& name) {
|
||||
if (enabled_) {
|
||||
write_polygon(polygon.outer_boundary(), name);
|
||||
@@ -461,20 +452,6 @@ class DebugWriter {
|
||||
}
|
||||
}
|
||||
|
||||
void write_polygons(const Arrangement_2& arr, const std::string& name) {
|
||||
if (enabled_) {
|
||||
// Just for the automatic numbering, create a full vector
|
||||
std::vector<Polygon_2> temp;
|
||||
for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) {
|
||||
if (it->is_unbounded()) {
|
||||
continue;
|
||||
}
|
||||
temp.push_back(circ_to_poly(it->outer_ccb()));
|
||||
}
|
||||
write_polygons(temp, name);
|
||||
}
|
||||
}
|
||||
|
||||
void write_polygons(const std::vector<Polygon_with_holes_2>& polygons, const std::string& name) {
|
||||
if (enabled_) {
|
||||
size_t i = 0;
|
||||
@@ -498,14 +475,7 @@ class DebugWriter {
|
||||
std::string last_segment_name_;
|
||||
|
||||
void write_polygon_to_svg_(std::ostream& ofs, const Polygon_2& polygon, const std::string& class_name = "") {
|
||||
auto class_name_ = class_name;
|
||||
if (!polygon.is_simple()) {
|
||||
if (!class_name_.empty()) {
|
||||
class_name_ += " ";
|
||||
}
|
||||
class_name_ += "self_intersecting";
|
||||
}
|
||||
ofs << "<polygon class=\"" + class_name_ + "\" points=\"";
|
||||
ofs << "<polygon class=\"" + class_name + "\" points=\"";
|
||||
for (auto vit = polygon.vertices_begin(); vit != polygon.vertices_end(); ++vit) {
|
||||
ofs << CGAL::to_double(vit->x()) << "," << -CGAL::to_double(vit->y()) << " ";
|
||||
}
|
||||
@@ -834,10 +804,6 @@ class SegmentLookup {
|
||||
return out;
|
||||
}
|
||||
|
||||
PolygonIt end() const {
|
||||
return polygons_ref_.end();
|
||||
}
|
||||
|
||||
private:
|
||||
using TreeTraits = CGAL::AABB_traits<K, CGAL::AABB_segment_primitive<K, std::list<CGAL::Segment_3<K>>::iterator>>;
|
||||
using Tree = CGAL::AABB_tree<TreeTraits>;
|
||||
@@ -850,33 +816,25 @@ private:
|
||||
std::map<Point_2, std::vector<Polygon_2>::const_iterator> input_polygon_boundary_cache_;
|
||||
};
|
||||
|
||||
Polygon_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_2& p, std::map<Point_2, SegmentLookup::PolygonIt>& point_lookup) {
|
||||
Polygon_2 subdivide_polygon(double max_distance, const Polygon_2 & p) {
|
||||
std::vector<Point_2> points;
|
||||
for (auto it = p.edges_begin(); it != p.edges_end(); ++it) {
|
||||
auto source_poly = segment_lookup.input_polygon_boundary(it->source());
|
||||
auto target_poly = segment_lookup.input_polygon_boundary(it->target());
|
||||
const auto& seg = *it;
|
||||
auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1;
|
||||
points.push_back(seg.source());
|
||||
if (source_poly == target_poly && source_poly != segment_lookup.end()) {
|
||||
point_lookup.emplace(seg.source(), source_poly);
|
||||
point_lookup.emplace(seg.target(), source_poly);
|
||||
auto num_splits = (int)std::ceil(std::sqrt(CGAL::to_double(seg.squared_length())) / max_distance) - 1;
|
||||
for (auto i = 0; i < num_splits; ++i) {
|
||||
auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1);
|
||||
auto p = seg.source() + d;
|
||||
point_lookup.emplace(p, source_poly);
|
||||
points.push_back(p);
|
||||
}
|
||||
for (auto i = 0; i < num_splits; ++i) {
|
||||
auto d = (seg.target() - seg.source()) / (num_splits + 1) * (i + 1);
|
||||
points.push_back(seg.source() + d);
|
||||
}
|
||||
}
|
||||
return Polygon_2(points.begin(), points.end());
|
||||
};
|
||||
|
||||
Polygon_with_holes_2 subdivide_polygon_on_same_input(SegmentLookup& segment_lookup, double max_distance, const Polygon_with_holes_2& pwh, std::map<Point_2, SegmentLookup::PolygonIt>& point_lookup) {
|
||||
Polygon_2 outer = subdivide_polygon_on_same_input(segment_lookup, max_distance, pwh.outer_boundary(), point_lookup);
|
||||
Polygon_with_holes_2 subdivide_polygon(double max_distance, const Polygon_with_holes_2& pwh) {
|
||||
Polygon_2 outer = subdivide_polygon(max_distance, pwh.outer_boundary());
|
||||
std::vector<Polygon_2> holes;
|
||||
for (auto hit = pwh.holes_begin(); hit != pwh.holes_end(); ++hit) {
|
||||
holes.push_back(subdivide_polygon_on_same_input(segment_lookup, max_distance, *hit, point_lookup));
|
||||
holes.push_back(subdivide_polygon(max_distance, *hit));
|
||||
}
|
||||
return Polygon_with_holes_2(outer, holes.begin(), holes.end());
|
||||
};
|
||||
@@ -887,7 +845,7 @@ std::tuple<
|
||||
std::map<std::pair<Point_2, Point_2>, std::vector<const CGAL::Polygon_2<K>*>>,
|
||||
std::map<Point_2, double>
|
||||
>
|
||||
build_line_graph(const std::vector<Polygon_2>& input_polygons, const std::map<Point_2, SegmentLookup::PolygonIt>& point_lookup, const std::vector<Polygon_2>& triangular_polygons)
|
||||
build_line_graph(const std::vector<Polygon_2>& input_polygons, SegmentLookup& segment_lookup, const std::vector<Polygon_2>& triangular_polygons)
|
||||
{
|
||||
|
||||
// Build maps of triangle -> edge and edge -> triangle in order to do traversal on the 'corridor mesh'
|
||||
@@ -916,17 +874,13 @@ build_line_graph(const std::vector<Polygon_2>& input_polygons, const std::map<Po
|
||||
for (auto& p : segment_to_facet) {
|
||||
auto center = CGAL::ORIGIN + (((p.first.first - CGAL::ORIGIN) + (p.first.second - CGAL::ORIGIN)) / 2);
|
||||
|
||||
auto p1index = point_lookup.find(p.first.first);
|
||||
auto p2index = point_lookup.find(p.first.second);
|
||||
auto p1index = segment_lookup.input_polygon_boundary(p.first.first);
|
||||
auto p2index = segment_lookup.input_polygon_boundary(p.first.second);
|
||||
|
||||
if (p1index == point_lookup.end() || p2index == point_lookup.end()) {
|
||||
continue;
|
||||
}
|
||||
segment_to_input_facet[p.first].push_back(&*p1index);
|
||||
segment_to_input_facet[p.first].push_back(&*p2index);
|
||||
|
||||
segment_to_input_facet[p.first].push_back(&*p1index->second);
|
||||
segment_to_input_facet[p.first].push_back(&*p2index->second);
|
||||
|
||||
if (p1index->second != input_polygons.end() && p2index->second != input_polygons.end() && p1index->second != p2index->second) {
|
||||
if (p1index != input_polygons.end() && p2index != input_polygons.end() && p1index != p2index) {
|
||||
segment_to_midpoint[p.first] = center;
|
||||
midpoint_to_segment[center] = p.first;
|
||||
midpoint_to_edge_length[center] = std::sqrt(CGAL::to_double(CGAL::squared_distance(p.first.first, p.first.second)));
|
||||
@@ -1085,45 +1039,6 @@ bool aabb_overlap(const DBox& a, const DBox& b, double eps = 1.e-9) {
|
||||
a[1].y() + eps >= b[0].y();
|
||||
}
|
||||
|
||||
std::pair<double, double> projected_interval_on_axis(const std::array<DPoint, 4>& points, const DDir& axis_u) {
|
||||
auto u = unit(axis_u);
|
||||
auto t0 = (points.front() - CGAL::ORIGIN) * u;
|
||||
auto interval = std::make_pair(t0, t0);
|
||||
for (auto& p : points) {
|
||||
auto t = (p - CGAL::ORIGIN) * u;
|
||||
interval.first = std::min(interval.first, t);
|
||||
interval.second = std::max(interval.second, t);
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
bool intervals_overlap(const std::pair<double, double>& a, const std::pair<double, double>& b, double eps = 1.e-9) {
|
||||
return a.first <= b.second + eps && b.first <= a.second + eps;
|
||||
}
|
||||
|
||||
bool obb_overlap(const std::array<DPoint, 4>& a, const std::array<DPoint, 4>& b, double eps = 1.e-9) {
|
||||
auto has_separating_axis = [&](const std::array<DPoint, 4>& points) {
|
||||
for (size_t i = 0; i < points.size(); ++i) {
|
||||
auto edge = points[(i + 1) % points.size()] - points[i];
|
||||
auto axis = unit(perpendicular(edge));
|
||||
if (axis.squared_length() < 1.e-18) {
|
||||
continue;
|
||||
}
|
||||
if (!intervals_overlap(projected_interval_on_axis(a, axis), projected_interval_on_axis(b, axis), eps)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return !has_separating_axis(a) && !has_separating_axis(b);
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
bool obb_overlap(const T& a, const U& b, double eps = 1.e-9) {
|
||||
return obb_overlap(a.corners, b.corners, eps);
|
||||
}
|
||||
|
||||
CenterLineGraphData make_center_line_graph_data(
|
||||
const std::map<Point_2, std::vector<Point_2>>& line_graph,
|
||||
const std::map<Point_2, double>& midpoint_to_edge_length)
|
||||
@@ -1417,9 +1332,6 @@ bool clusters_can_merge(const BoxCluster& a, const BoxCluster& b, double angle_t
|
||||
if (!aabb_overlap(a.box.bbox, b.box.bbox)) {
|
||||
return false;
|
||||
}
|
||||
if (!obb_overlap(a.box, b.box)) {
|
||||
return false;
|
||||
}
|
||||
if (angle_between_dirs_deg(a.box.direction, b.box.direction) > angle_tol_deg) {
|
||||
return false;
|
||||
}
|
||||
@@ -1549,8 +1461,8 @@ double point_to_oriented_box_distance(const DPoint& p, const MergedBoxRecord& bo
|
||||
|
||||
std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
const CenterLineGraphData& graph,
|
||||
const std::vector<MergedBoxRecord>& boxes,
|
||||
const K::FT& max_projection_distance) {
|
||||
const std::vector<MergedBoxRecord>& boxes)
|
||||
{
|
||||
std::vector<Point_2> snapped_points(graph.points.size());
|
||||
|
||||
for (size_t i = 0; i < graph.points.size(); ++i) {
|
||||
@@ -1609,13 +1521,7 @@ std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
}
|
||||
return a.line_distance < b.line_distance;
|
||||
});
|
||||
|
||||
if ((graph.points[i] - best.projection).squared_length() < (max_projection_distance * max_projection_distance)) {
|
||||
snapped_points[i] = best.projection;
|
||||
} else {
|
||||
snapped_points[i] = graph.points[i];
|
||||
std::cout << "Warning: snapping distance exceeding distance: " << std::sqrt(CGAL::to_double((snapped_points[i] - best.projection).squared_length())) << " > " << max_projection_distance << std::endl;
|
||||
}
|
||||
snapped_points[i] = best.projection;
|
||||
}
|
||||
|
||||
std::map<Point_2, std::set<Point_2>> adjacency;
|
||||
@@ -1639,8 +1545,8 @@ std::map<Point_2, std::vector<Point_2>> snap_points_to_box_axes(
|
||||
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,
|
||||
const K::FT& max_projection_distance) {
|
||||
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) {
|
||||
@@ -1671,7 +1577,7 @@ Graph2D<K> join_segment_runs(
|
||||
}
|
||||
debug.write_polygons(run_polygons, "merged_boxes");
|
||||
|
||||
auto snapped_graph = snap_points_to_box_axes(graph, boxes, max_projection_distance);
|
||||
auto snapped_graph = snap_points_to_box_axes(graph, boxes);
|
||||
return Graph2D<K>(snapped_graph);
|
||||
}
|
||||
|
||||
@@ -2163,104 +2069,6 @@ std::list<std::pair<Point_2, Point_2>> extend_end_vertices_based_on_input(
|
||||
return constructed_segments;
|
||||
}
|
||||
|
||||
std::list<std::pair<Point_2, Point_2>>
|
||||
extend_end_vertices_based_on_input_simple(
|
||||
const Graph2D<K>& G,
|
||||
const Polygon_list& outer_perimiter,
|
||||
const K::FT& max_projection_distance)
|
||||
{
|
||||
auto max_intersection_distance = max_projection_distance / 4;
|
||||
std::list<std::pair<Point_2, Point_2>> constructed_segments;
|
||||
|
||||
for (auto it = G.vertices_begin(); it != G.vertices_end(); ++it) {
|
||||
if (it->second.size() == 1) {
|
||||
auto& M = it->first;
|
||||
|
||||
for (auto& bnd : outer_perimiter) {
|
||||
// if point M is contained in bnd interior:
|
||||
// if (!bnd.has_on_unbounded_side(M)) {
|
||||
if (bnd.has_on_bounded_side(M)) {
|
||||
auto& incoming = *it->second.begin();
|
||||
// create ray incoming -> M
|
||||
CGAL::Ray_2<K> ray(incoming, M - incoming);
|
||||
|
||||
// intersect ray with boundary
|
||||
boost::optional<CGAL::Segment_2<K>> closest_segment;
|
||||
boost::optional<CGAL::Point_2<K>> closest_intersection_point;
|
||||
K::FT sq_distance_along_ray = std::numeric_limits<double>::infinity();
|
||||
for (auto jt = bnd.edges_begin(); jt != bnd.edges_end(); ++jt) {
|
||||
const auto& seg = *jt;
|
||||
auto x = CGAL::intersection(ray, seg);
|
||||
if (x) {
|
||||
if (auto* xp = variant_get<CGAL::Point_2<K>>(&*x)) {
|
||||
auto dist = ((*xp) - M).squared_length();
|
||||
if (dist < sq_distance_along_ray) {
|
||||
if (dist < (max_intersection_distance * max_intersection_distance)) {
|
||||
closest_segment = seg;
|
||||
closest_intersection_point = *xp;
|
||||
sq_distance_along_ray = dist;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest_intersection_point) {
|
||||
constructed_segments.push_front({M, *closest_intersection_point});
|
||||
} else {
|
||||
|
||||
// Loop over boundary segments, and project point onto it, take the closest
|
||||
K::FT closest_distance = std::numeric_limits<double>::infinity();
|
||||
boost::optional<CGAL::Point_2<K>> closest_point;
|
||||
for (auto& poly : outer_perimiter) {
|
||||
for (auto jt = poly.edges_begin(); jt != poly.edges_end(); ++jt) {
|
||||
auto seg = *jt;
|
||||
auto Pp = seg.supporting_line().projection(M);
|
||||
if (seg.has_on(Pp)) {
|
||||
auto d = CGAL::squared_distance(Pp, M);
|
||||
if (d < (max_projection_distance * max_projection_distance)) {
|
||||
if (d < closest_distance) {
|
||||
closest_distance = d;
|
||||
closest_point = Pp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest_point) {
|
||||
constructed_segments.push_front({M, *closest_point});
|
||||
} else {
|
||||
|
||||
for (auto& poly : outer_perimiter) {
|
||||
for (auto it = poly.begin(); it != poly.end(); ++it) {
|
||||
auto Pp = *it;
|
||||
auto d = CGAL::squared_distance(Pp, M);
|
||||
if (d < (max_projection_distance * max_projection_distance)) {
|
||||
if (d < closest_distance) {
|
||||
closest_distance = d;
|
||||
closest_point = Pp;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closest_point) {
|
||||
constructed_segments.push_front({M, *closest_point});
|
||||
} else {
|
||||
std::cout << "Unable to find projection or intersection point for interior boundary (" << M.x() << " " << M.y() << ")" << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return constructed_segments;
|
||||
}
|
||||
|
||||
void fuse_corridor_halves_with_input(Arrangement_2& arr, Graph2D<K>& G, SegmentLookup& segment_lookup, const Polygon_list& input_polygons, DebugWriter& debug_output) {
|
||||
std::set<Arrangement_2::Halfedge_handle> edges_to_remove;
|
||||
|
||||
@@ -2353,7 +2161,7 @@ class Segment_2_less {
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2& left, Arrangement_2& right) {
|
||||
std::vector<K::FT> arrangement_cell_iou(Arrangement_2& left, Arrangement_2& right) {
|
||||
|
||||
using Walk_pl = CGAL::Arr_walk_along_line_point_location<Arrangement_2>;
|
||||
Walk_pl walk_pl(right);
|
||||
@@ -2362,9 +2170,6 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
|
||||
std::vector<K::FT> return_values;
|
||||
|
||||
K::FT max_iou_deviation = 1;
|
||||
std::array<Polygon_2, 2> max_deviation_poly_pair;
|
||||
|
||||
for (auto it = left.faces_begin(); it != left.faces_end(); ++it) {
|
||||
if (!it->is_unbounded()) {
|
||||
// convert arr facet to polygon with holes
|
||||
@@ -2373,9 +2178,6 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
for (auto hit = it->inner_ccbs_begin(); hit != it->inner_ccbs_end(); ++hit) {
|
||||
pwh.add_hole(circ_to_poly(*hit));
|
||||
}
|
||||
// if (!pwh.outer_boundary().is_simple()) {
|
||||
// throw std::runtime_error("Polygon with holes has a non-simple outer boundary");
|
||||
// }
|
||||
|
||||
CGAL::Polygon_triangulation_decomposition_2<K> decompositor;
|
||||
std::vector<Polygon_2> temp;
|
||||
@@ -2416,26 +2218,10 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
}
|
||||
}
|
||||
|
||||
if (max_score == -std::numeric_limits<double>::infinity()) {
|
||||
// no more points to try
|
||||
return_values.push_back(0);
|
||||
break;
|
||||
}
|
||||
|
||||
visited_points.insert(best_point);
|
||||
|
||||
debug_output.write_point(best_point, "representative_point representative_point_" + std::to_string(std::distance(left.faces_begin(), it)));
|
||||
|
||||
auto res = walk_pl.locate(best_point);
|
||||
if (auto* v = variant_get<Arrangement_2::Face_const_handle>(&res)) {
|
||||
if ((*v)->is_unbounded()) {
|
||||
// try next point
|
||||
continue;
|
||||
}
|
||||
if (visited_faces_on_right.count(*v) > 0) {
|
||||
// Maybe we should be more permissive, try some other points etc.
|
||||
return_values.push_back(0);
|
||||
std::cout << "Already visited face on right, skipping point\n";
|
||||
} else {
|
||||
// convert arr facet to polygon with holes
|
||||
auto polygon_exterior = circ_to_poly((*v)->outer_ccb());
|
||||
@@ -2443,9 +2229,6 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
for (auto hit = (*v)->inner_ccbs_begin(); hit != (*v)->inner_ccbs_end(); ++hit) {
|
||||
pwh_right.add_hole(circ_to_poly(*hit));
|
||||
}
|
||||
// if (!pwh_right.outer_boundary().is_simple()) {
|
||||
// throw std::runtime_error("Polygon with holes has a non-simple outer boundary");
|
||||
// }
|
||||
|
||||
// compute intersection over union of pwh and the original polygon
|
||||
if (CGAL::do_intersect(pwh, pwh_right)) {
|
||||
@@ -2455,7 +2238,7 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
for (auto& r : result) {
|
||||
auto poly_area = r.outer_boundary().area();
|
||||
for (auto& h : r.holes()) {
|
||||
poly_area -= CGAL::abs(h.area());
|
||||
poly_area -= h.area();
|
||||
}
|
||||
intersection_area += poly_area;
|
||||
}
|
||||
@@ -2463,17 +2246,10 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
CGAL::join(pwh, pwh_right, poly12);
|
||||
typename K::FT union_area = poly12.outer_boundary().area();
|
||||
for (auto& h : poly12.holes()) {
|
||||
union_area -= CGAL::abs(h.area());
|
||||
union_area -= h.area();
|
||||
}
|
||||
return_values.push_back(intersection_area / union_area);
|
||||
|
||||
auto& v = return_values.back();
|
||||
if (v < max_iou_deviation) {
|
||||
max_iou_deviation = v;
|
||||
max_deviation_poly_pair = {pwh.outer_boundary(), pwh_right.outer_boundary()};
|
||||
}
|
||||
} else {
|
||||
std::cout << "No intersection, skipping point\n";
|
||||
return_values.push_back(0);
|
||||
}
|
||||
}
|
||||
@@ -2487,11 +2263,6 @@ std::vector<K::FT> arrangement_cell_iou(DebugWriter& debug_output, Arrangement_2
|
||||
}
|
||||
}
|
||||
|
||||
if (max_iou_deviation != 1) {
|
||||
debug_output.write_polygon(max_deviation_poly_pair[0], "max_iou_deviation_left");
|
||||
debug_output.write_polygon(max_deviation_poly_pair[1], "max_iou_deviation_right");
|
||||
}
|
||||
|
||||
return return_values;
|
||||
}
|
||||
|
||||
@@ -3164,20 +2935,7 @@ class timer {
|
||||
bool enabled_;
|
||||
};
|
||||
|
||||
size_t delete_same_facet_edge_pairs(Arrangement_2& arr) {
|
||||
size_t n_deleted = 0;
|
||||
for (auto it = arr.edges_begin(); it != arr.edges_end();) {
|
||||
decltype(it) current = it++;
|
||||
if (current->face() == current->twin()->face()) {
|
||||
arr.remove_edge(current);
|
||||
n_deleted++;
|
||||
}
|
||||
}
|
||||
return n_deleted;
|
||||
}
|
||||
|
||||
void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std::vector<Polygon_2>& input_polygons_, std::vector<Polygon_2>& output_polygons, double polygon_offset_distance = -1.) {
|
||||
|
||||
static const double OVERLAP_RESOLUTION_DISTANCE = 1.e-1;
|
||||
// even larger amount of inset so that outer perimeter is safely within all input polygons even when overlap resolution is applied
|
||||
// no, `1.e-2 + 1.e-5` creates issues with the outer perimeter, are there other tolerances in play?
|
||||
@@ -3339,17 +3097,13 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
t0.stop();
|
||||
t0 = timer.start("corridor triangulation");
|
||||
|
||||
SegmentLookup segment_lookup(input_polygons);
|
||||
|
||||
// subdivide difference_result to have better more detailed triangulation and therefore less-pronounced artefacts in midpoint network
|
||||
|
||||
// We store correspondence of subdivision points to input polygons when subdividing so that we do not need to query, which is expensive, when building the line graph later on.
|
||||
std::map<Point_2, SegmentLookup::PolygonIt> point_lookup;
|
||||
|
||||
auto subdivision_length = polygon_offset_distance / settings.subdivision_factor;
|
||||
|
||||
for (auto& pwh : difference_result) {
|
||||
difference_result_subdivided.push_back(subdivide_polygon_on_same_input(segment_lookup, subdivision_length, pwh, point_lookup));
|
||||
difference_result_subdivided.push_back(subdivide_polygon(subdivision_length, pwh));
|
||||
// difference_result_subdivided.push_back(subdivide_polygon(polygon_offset_distance / 64., pwh));
|
||||
}
|
||||
|
||||
debug_output.write_polygons(difference_result_subdivided, "corridor_subdivided");
|
||||
@@ -3374,7 +3128,9 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
|
||||
debug_output.write_polygons(triangular_polygons, "triangulated_corridor");
|
||||
|
||||
auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, point_lookup, triangular_polygons);
|
||||
SegmentLookup segment_lookup(input_polygons);
|
||||
|
||||
auto [line_graph, midpoint_to_segment, segment_to_input_facet, midpoint_to_edge_length] = build_line_graph(input_polygons, segment_lookup, triangular_polygons);
|
||||
for (auto& p : line_graph) {
|
||||
for (auto& q : p.second) {
|
||||
debug_output.write_segment(p.first, q, "network_1");
|
||||
@@ -3386,45 +3142,8 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
t0 = timer.start("center line cleaning");
|
||||
|
||||
Graph2D<K> G;
|
||||
|
||||
{
|
||||
// this is applied for both algos
|
||||
auto eliminated_segments = eliminate_triangles(line_graph);
|
||||
for (auto e : eliminated_segments) {
|
||||
debug_output.write_segment(e.first, e.second, "eliminated");
|
||||
for (int i = 0; i < 2; ++i) {
|
||||
auto it = line_graph.find(e.first);
|
||||
if (it == line_graph.end()) {
|
||||
std::cerr << "Warning: unable to locate vertex for elimination, skipping" << std::endl;
|
||||
continue;
|
||||
}
|
||||
auto& neighbours = it->second;
|
||||
neighbours.erase(std::remove(neighbours.begin(), neighbours.end(), e.second), neighbours.end());
|
||||
if (neighbours.empty()) {
|
||||
line_graph.erase(it);
|
||||
}
|
||||
std::swap(e.first, e.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Graph2D<K> G_orig(line_graph);
|
||||
|
||||
auto apply_line_cleaning_algo_1 = [&]() {
|
||||
Graph2D<K> G2(line_graph);
|
||||
G = G2.weld_vertices();
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
eliminate_colinear_vertices(G);
|
||||
edge_slide(G);
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_3");
|
||||
}
|
||||
};
|
||||
|
||||
if (settings.line_cleaning_algo == 0) {
|
||||
G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length, subdivision_length * 4);
|
||||
G = join_segment_runs(debug_output, line_graph, midpoint_to_edge_length);
|
||||
Arrangement_2 arr;
|
||||
G.to_arrangement(arr);
|
||||
Graph2D<K> G2;
|
||||
@@ -3435,78 +3154,34 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
} else {
|
||||
apply_line_cleaning_algo_1();
|
||||
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);
|
||||
}
|
||||
|
||||
G = G2.weld_vertices();
|
||||
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_2");
|
||||
}
|
||||
|
||||
eliminate_colinear_vertices(G);
|
||||
|
||||
edge_slide(G);
|
||||
|
||||
for (auto it = G.edges_begin(); it != G.edges_end(); ++it) {
|
||||
debug_output.write_segment(it->first, it->second, "network_3");
|
||||
}
|
||||
}
|
||||
|
||||
t0.stop();
|
||||
|
||||
t0 = timer.start("topology");
|
||||
|
||||
std::list<std::pair<Point_2, Point_2>> segments, segments1, segments2;
|
||||
bool fallback_to_line_cleaning_algo_1 = false;
|
||||
|
||||
if (settings.line_cleaning_algo == 0) {
|
||||
segments1 = extend_end_vertices_based_on_input_simple(G, outer_perimiter, subdivision_length * 16);
|
||||
segments2 = extend_end_vertices_based_on_input_simple(G_orig, outer_perimiter, subdivision_length * 16);
|
||||
|
||||
Arrangement_2 arr_clean;
|
||||
G.to_arrangement(arr_clean);
|
||||
for (auto& pq : segments1) {
|
||||
if (pq.first == pq.second) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert(arr_clean, Segment_2(pq.first, pq.second));
|
||||
}
|
||||
|
||||
Arrangement_2 arr_orig;
|
||||
G_orig.to_arrangement(arr_orig);
|
||||
for (auto& pq : segments2) {
|
||||
if (pq.first == pq.second) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert(arr_orig, Segment_2(pq.first, pq.second));
|
||||
}
|
||||
|
||||
for (auto& p : outer_perimiter) {
|
||||
for (auto it = p.edges_begin(); it != p.edges_end(); ++it) {
|
||||
auto source = it->source();
|
||||
auto target = it->target();
|
||||
if (source == target) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert(arr_orig, Segment_2(source, target));
|
||||
CGAL::insert(arr_clean, Segment_2(source, target));
|
||||
}
|
||||
}
|
||||
|
||||
delete_same_facet_edge_pairs(arr_clean);
|
||||
delete_same_facet_edge_pairs(arr_orig);
|
||||
|
||||
debug_output.write_polygons(arr_clean, "iou_left");
|
||||
debug_output.write_polygons(arr_orig, "iou_right");
|
||||
|
||||
auto ious = arrangement_cell_iou(debug_output, arr_clean, arr_orig);
|
||||
/*
|
||||
for (auto& iou : ious) {
|
||||
std::cout << " " << CGAL::to_double(iou - 1);
|
||||
}
|
||||
std::cout << std::endl;
|
||||
*/
|
||||
|
||||
auto it = std::min_element(ious.begin(), ious.end());
|
||||
|
||||
if (it != ious.end() && (*it < 0.45)) {
|
||||
std::cerr << "Significant difference between cleaned and original arrangement, using original for topology reconstruction: " << *it << std::endl;
|
||||
fallback_to_line_cleaning_algo_1 = true;
|
||||
apply_line_cleaning_algo_1();
|
||||
} else {
|
||||
segments = segments1;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.line_cleaning_algo != 0 || fallback_to_line_cleaning_algo_1) {
|
||||
segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4);
|
||||
}
|
||||
auto segments = extend_end_vertices_based_on_input(G, midpoint_to_segment, segment_to_input_facet, outer_perimiter, segment_lookup, subdivision_length * 4);
|
||||
|
||||
// Now plot the edges on an arrangement in order to find planar cycles
|
||||
// and merge the corridor-halves with their neighbouring input polygon
|
||||
@@ -3548,7 +3223,15 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
}
|
||||
}
|
||||
|
||||
debug_output.write_polygons(arr, "arr_faces");
|
||||
// Just for the automatic numbering, create a full vector
|
||||
std::vector<Polygon_2> temp;
|
||||
for (auto it = arr.faces_begin(); it != arr.faces_end(); ++it) {
|
||||
if (it->is_unbounded()) {
|
||||
continue;
|
||||
}
|
||||
temp.push_back(circ_to_poly(it->outer_ccb()));
|
||||
}
|
||||
debug_output.write_polygons(temp, "arr_faces");
|
||||
|
||||
|
||||
/* {
|
||||
@@ -3573,7 +3256,7 @@ void arrange_cgal_polygons(svgfill::arrange_polygon_settings settings, const std
|
||||
double threshold;
|
||||
clean_noisy_paths(debug_output, arr, segment_lookup, threshold);
|
||||
remove_colinear_vertices(arr);
|
||||
// clean_noisy_bounds(debug_output, arr, segment_lookup, threshold);
|
||||
clean_noisy_bounds(debug_output, arr, segment_lookup, threshold);
|
||||
}
|
||||
|
||||
t0.stop();
|
||||
|
||||
@@ -178,9 +178,6 @@ public:
|
||||
std::vector<CGAL::Segment_2<Kernel>> segments;
|
||||
for (const auto& p : adjacency_list) {
|
||||
for (const auto& q : p.second) {
|
||||
if (p.first == q) {
|
||||
return false;
|
||||
}
|
||||
if (p.first < q) {
|
||||
segments.emplace_back(p.first, q);
|
||||
}
|
||||
@@ -201,7 +198,7 @@ public:
|
||||
any = true;
|
||||
}
|
||||
});
|
||||
return !any;
|
||||
return any;
|
||||
}
|
||||
|
||||
// Eliminates a vertex with exactly two neighbors by connecting its neighbors
|
||||
@@ -341,21 +338,12 @@ public:
|
||||
|
||||
template <typename T>
|
||||
void to_arrangement(T& arr) {
|
||||
if (is_valid() && arr.is_empty()) {
|
||||
std::vector<CGAL::Segment_2<Kernel>> edges;
|
||||
|
||||
for (auto it = edges_begin(); it != edges_end(); ++it) {
|
||||
edges.emplace_back(it->first, it->second);
|
||||
for (auto it = edges_begin(); it != edges_end(); ++it) {
|
||||
if (it->first == it->second) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert_non_intersecting_curves(arr, edges.begin(), edges.end());
|
||||
} else {
|
||||
for (auto it = edges_begin(); it != edges_end(); ++it) {
|
||||
if (it->first == it->second) {
|
||||
continue;
|
||||
}
|
||||
CGAL::insert(arr, CGAL::Segment_2<Kernel>(it->first, it->second));
|
||||
}
|
||||
}
|
||||
CGAL::insert(arr, CGAL::Segment_2<Kernel>(it->first, it->second));
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
|
||||
Reference in New Issue
Block a user