Light: Optimized radiance rendering workflow

This commit is contained in:
Chirag Singh
2026-03-21 18:25:49 +05:30
parent f40377b93d
commit c627d59bda
6 changed files with 1041 additions and 265 deletions
@@ -52,6 +52,7 @@ classes = (
operator.AddIESLight,
operator.RemoveIESLight,
operator.SetIESLightObject,
operator.CleanupRadianceFiles,
prop.RadianceMaterial,
prop.IESLight,
prop.BIMSolarProperties,
@@ -64,7 +65,7 @@ classes = (
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:
@@ -77,5 +78,5 @@ def register():
def unregister():
del bpy.types.Scene.BIMRadianceExporeterProperies
del bpy.types.Scene.BIMRadianceExporterProperties
del bpy.types.Scene.BIMSolarProperties
+8 -11
View File
@@ -18,17 +18,13 @@
from __future__ import annotations
import json
import os
import bpy
from typing import TYPE_CHECKING
from pathlib import Path
if TYPE_CHECKING:
from bonsai.bim.module.light.prop import RadianceExporterProperties, RadianceMaterial, IESLight
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(
@@ -89,12 +85,13 @@ class MATERIAL_UL_ies_lights(bpy.types.UIList):
else:
row.label(text="(No file selected)", icon="ERROR")
# Target object property (displays object name)
row.prop(item, "target_object", text="", emboss=False)
# Object eyedropper picker button (like in modifiers)
op = row.operator("radiance.set_ies_light_object", text="", icon="EYEDROPPER")
op.index = index
# 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)
op = row.operator("radiance.set_ies_light_object", text="", icon="EYEDROPPER")
op.index = index
# Remove button (X icon - negative action)
op = row.operator("radiance.remove_ies_light", text="", icon="X")
File diff suppressed because it is too large Load Diff
+97 -14
View File
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
import calendar
import datetime
import json
import os
@@ -43,7 +44,6 @@ from bonsai.bim.module.light.data import SolarData
from bonsai.bim.module.light.decorator import SolarDecorator
sun_position = tool.Blender.get_addon("sun_position")
now = datetime.datetime.now()
with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f:
spectraldb: dict[str, dict[str, str]] = json.load(f)
@@ -72,8 +72,8 @@ def update_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -
def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
sun_props = tool.Blender.get_sun_props()
assert sun_props
sun_props.longitude = sun_props.longitude
sun_props.latitude = sun_props.latitude
sun_props.longitude = self.longitude
sun_props.latitude = self.latitude
self["coordinates"] = sun_props.coordinates
update_sun_path(self)
@@ -135,6 +135,14 @@ def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Con
context.scene.render.resolution_y = self.radiance_resolution_y
def update_day(self: "BIMSolarProperties", context: bpy.types.Context) -> None:
"""Clamp the day to the valid range for the current month/year."""
max_day = calendar.monthrange(self.year, self.month)[1]
if self.day > max_day:
self["day"] = max_day
update_sun_path(self)
def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context, None] = None) -> None:
if not SolarData.is_loaded:
SolarData.load()
@@ -152,9 +160,13 @@ def update_sun_path(self: "BIMSolarProperties", context: Union[bpy.types.Context
sun_props.sun_distance = self.sun_path_size
sun_props.latitude = self.latitude
sun_props.longitude = self.longitude
# Clamp day to valid range for the current month/year
max_day = calendar.monthrange(self.year, self.month)[1]
day = min(self.day, max_day)
sun_props.year = self.year
sun_props.month = self.month
sun_props.day = self.day
sun_props.day = day
sun_props.time = self.hour + (self.minute / 60)
# Preserve IFC sign convention
sun_props.north_offset = self.true_north * -1
@@ -230,7 +242,12 @@ class RadianceMaterial(PropertyGroup):
class IESLight(PropertyGroup):
"""Represents a mapping between an IES light file and a scene Empty object."""
"""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",
@@ -238,12 +255,22 @@ class IESLight(PropertyGroup):
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)",
@@ -288,9 +315,24 @@ class IESLight(PropertyGroup):
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
@@ -326,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"]
@@ -338,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
@@ -358,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 = ""
@@ -369,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", ""),
@@ -436,6 +496,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",
@@ -569,6 +638,15 @@ class RadianceExporterProperties(PropertyGroup):
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",
@@ -605,6 +683,8 @@ class RadianceExporterProperties(PropertyGroup):
if TYPE_CHECKING:
is_exporting: bool
is_preparing: bool
is_rendering: bool
category: str
subcategory: str
materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial]
@@ -614,6 +694,7 @@ class RadianceExporterProperties(PropertyGroup):
radiance_resolution_y: int
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"]
@@ -632,6 +713,7 @@ class RadianceExporterProperties(PropertyGroup):
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
@@ -662,11 +744,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)
+42 -8
View File
@@ -130,6 +130,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = box.row()
row.prop(props, "radiance_variability")
row = box.row()
row.prop(props, "ambient_bounces")
row = box.row()
row.prop(props, "output_file_name")
@@ -140,10 +143,6 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = box.row()
row.prop(props, "use_hdr")
if props.use_hdr:
row = box.row()
row.prop(props, "choose_hdr_image")
layout.separator()
# Sky Generation Settings
@@ -179,6 +178,21 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
box = layout.box()
box.label(text="Selected Light Properties")
# Target mode toggle
row = box.row()
row.prop(active_light, "use_collection", text="Target Collection", icon="OUTLINER_COLLECTION")
# Show the appropriate target picker
row = box.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 = box.row()
row.label(text=f"{len(empties)} empty object(s) in collection", icon="INFO")
else:
row.prop(active_light, "target_object", text="Object")
# Rotation Z
row = box.row()
row.prop(active_light, "rotation_z")
@@ -213,7 +227,10 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
box = layout.box()
box.label(text="Step 2: Prepare Radiance scene")
row = box.row()
row.operator("scene.prepare_radiance", text="Prepare Scene")
if props.is_preparing:
row.label(text="Preparing scene...", icon="SORTTIME")
else:
row.operator("scene.prepare_radiance", text="Prepare Scene")
layout.separator()
@@ -221,8 +238,11 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
box = layout.box()
box.label(text="Step 3: Run the simulation")
row = box.row()
row.operator("render_scene.radiance", text="Radiance Render")
row.enabled = not props.is_exporting
if props.is_rendering:
row.label(text="Rendering in progress...", icon="RENDER_STILL")
else:
row.operator("render_scene.radiance", text="Radiance Render")
row.enabled = not props.is_exporting
layout.separator()
@@ -240,6 +260,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row.label(text="Scale Factor")
row.prop(props, "false_color_scale", text="")
row = box.row()
row.prop(props, "false_color_steps")
row = box.row()
row.prop(props, "false_color_contour_lines")
@@ -259,6 +282,14 @@ class BIM_PT_radiance_exporter(bpy.types.Panel):
row = box.row()
row.operator("render_scene.false_color_radiance", text="Generate False Color Image")
layout.separator()
# Cleanup
box = layout.box()
box.label(text="Cleanup")
row = box.row()
row.operator("radiance.cleanup_files", text="Cleanup Generated Files", icon="TRASH")
class BIM_PT_solar(bpy.types.Panel):
"""Creates a Panel in the render properties window"""
@@ -375,7 +406,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")
+1 -1
View File
@@ -1845,7 +1845,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: