mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-06 16:01:36 +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()
|
||||
@@ -9,13 +9,6 @@ jobs:
|
||||
container: rockylinux:9
|
||||
|
||||
steps:
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -24,6 +17,7 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -51,7 +45,7 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
@@ -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@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
|
||||
- name: Install Python
|
||||
# Installs latest Python version so it's preferred by uv over Rocky's system Python.
|
||||
run: uv python install
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
dnf update -y
|
||||
@@ -24,6 +17,7 @@ jobs:
|
||||
sqlite-devel bzip2-devel zlib-devel openssl-devel xz-devel \
|
||||
readline-devel ncurses-devel libffi-devel libuuid-devel git-lfs \
|
||||
findutils xz byacc
|
||||
python3 -m pip install typing_extensions
|
||||
git config --global --add safe.directory '*'
|
||||
|
||||
- name: Install aws cli
|
||||
@@ -51,7 +45,7 @@ jobs:
|
||||
- name: Unpack Dependencies
|
||||
run: |
|
||||
cd build
|
||||
uv run ../nix/cache_dependencies.py unpack
|
||||
python3 ../nix/cache_dependencies.py unpack
|
||||
|
||||
- name: ccache
|
||||
uses: hendrikmuhs/ccache-action@v1.2.22
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
name: Publish Bonsai Releases
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v3
|
||||
|
||||
- run: uv run .github/scripts/publish-bonsai-releases.py
|
||||
env:
|
||||
BLENDER_EXTENSIONS_TOKEN: ${{ secrets.BLENDER_EXTENSIONS_TOKEN }}
|
||||
+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.
|
||||
|
||||
|
||||
+6
-3
@@ -3,7 +3,7 @@ name = "IfcOpenShell"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"black==26.3.1",
|
||||
"ruff==0.15.10",
|
||||
"ruff==0.15.9",
|
||||
"poethepoet",
|
||||
"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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -45,36 +45,6 @@ import bonsai.bim.import_ifc
|
||||
import bonsai.core.tool
|
||||
import bonsai.tool as tool
|
||||
|
||||
# For loading and caching remote texture images
|
||||
from pathlib import Path
|
||||
from urllib.request import urlretrieve
|
||||
from urllib.parse import urlsplit, urlunsplit, quote
|
||||
import hashlib
|
||||
|
||||
CACHE_DIR = Path.home() / ".blender_texture_cache"
|
||||
CACHE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def encode_url(url: str) -> str:
|
||||
# make sure the URL is clean (e.g., special characters like spaces are encoded)
|
||||
parts = urlsplit(url)
|
||||
return urlunsplit((parts.scheme, parts.netloc, quote(parts.path), parts.query, parts.fragment))
|
||||
|
||||
|
||||
def get_cached_path(url: str) -> Path:
|
||||
# Hash the URL -> unique filename
|
||||
h = hashlib.md5(url.encode()).hexdigest()
|
||||
ext = Path(urlsplit(url).path).suffix or ".img"
|
||||
return CACHE_DIR / f"{h}{ext}"
|
||||
|
||||
|
||||
def load_image_once(path: str):
|
||||
for img in bpy.data.images:
|
||||
if img.filepath == path:
|
||||
return img
|
||||
return bpy.data.images.load(path)
|
||||
|
||||
|
||||
# Progressively we'll refactor loading elements into Blender objects into this
|
||||
# class. This will break down the monolithic import_ifc module and allow us to
|
||||
# partially load and unload objects for huge models, partial model editing, and
|
||||
@@ -329,44 +299,23 @@ class Loader(bonsai.core.tool.Loader):
|
||||
if texture["type"] == "IfcImageTexture":
|
||||
# IFC2X3 uses UrlReference, IFC4+ uses URLReference.
|
||||
original_image_url = texture.get("URLReference") or texture.get("UrlReference", "")
|
||||
is_relative = not os.path.isabs(original_image_url)
|
||||
nonlocal image_url
|
||||
image_url = Path(original_image_url)
|
||||
if is_relative:
|
||||
ifc_path = Path(tool.Ifc.get_path())
|
||||
image_url = ifc_path.parent / image_url
|
||||
image_url = image_url.absolute().resolve()
|
||||
|
||||
is_remote = original_image_url.startswith(("http://", "https://"))
|
||||
if not image_url.exists():
|
||||
print(f"WARNING. Couldn't find texture by path {image_url}, it will be skipped.")
|
||||
return
|
||||
|
||||
if is_remote:
|
||||
try:
|
||||
encoded_url = encode_url(original_image_url)
|
||||
cache_path = get_cached_path(original_image_url)
|
||||
|
||||
# Download only once
|
||||
if not cache_path.exists():
|
||||
urlretrieve(encoded_url, cache_path)
|
||||
|
||||
# load via Blender
|
||||
return load_image_once(str(cache_path))
|
||||
|
||||
except Exception as e:
|
||||
print(
|
||||
f"WARNING. Failed to load remote texture {original_image_url}, it will be skipped: {e}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
is_relative = not os.path.isabs(original_image_url)
|
||||
nonlocal image_url
|
||||
image_url = Path(original_image_url)
|
||||
if is_relative:
|
||||
ifc_path = Path(tool.Ifc.get_path())
|
||||
image_url = ifc_path.parent / image_url
|
||||
image_url = image_url.absolute().resolve()
|
||||
|
||||
if not image_url.exists():
|
||||
print(f"WARNING. Couldn't find texture by path {image_url}, it will be skipped.")
|
||||
return
|
||||
|
||||
# keep url relative if it was before
|
||||
image_url = str(image_url)
|
||||
if is_relative and bpy.data.filepath:
|
||||
image_url = bpy.path.relpath(image_url)
|
||||
return bpy.data.images.load(image_url)
|
||||
# keep url relative if it was before
|
||||
image_url = str(image_url)
|
||||
if is_relative and bpy.data.filepath:
|
||||
image_url = bpy.path.relpath(image_url)
|
||||
return bpy.data.images.load(image_url)
|
||||
|
||||
elif texture["type"] == "IfcBlobTexture":
|
||||
# https://blender.stackexchange.com/questions/173206/how-to-efficiently-convert-a-pil-image-to-bpy-types-image
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3256,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();
|
||||
|
||||
Reference in New Issue
Block a user