From a570d81fd7a862e8654268d207429e4cbbafc574 Mon Sep 17 00:00:00 2001 From: Chirag Singh Date: Sat, 15 Nov 2025 21:40:13 +0530 Subject: [PATCH] Light(Radiance): Implemented IES File Mapping | Render False Color Images | Updated UI --- .../bonsai/bim/module/light/__init__.py | 7 + src/bonsai/bonsai/bim/module/light/list.py | 42 +- .../bonsai/bim/module/light/operator.py | 575 ++++++++++++++++-- src/bonsai/bonsai/bim/module/light/prop.py | 157 +++++ src/bonsai/bonsai/bim/module/light/ui.py | 101 ++- 5 files changed, 817 insertions(+), 65 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/light/__init__.py b/src/bonsai/bonsai/bim/module/light/__init__.py index 5cae142053..0055d8ece9 100644 --- a/src/bonsai/bonsai/bim/module/light/__init__.py +++ b/src/bonsai/bonsai/bim/module/light/__init__.py @@ -38,6 +38,8 @@ classes = ( operator.ImportTrueNorth, operator.MoveSunPathTo3DCursor, operator.RadianceRender, + operator.FalseColorRadiance, + operator.ConvertHDRToFootCandles, operator.ViewFromSun, operator.LightPickCoordinates, operator.LightSetTimeToNow, @@ -50,12 +52,17 @@ classes = ( operator.EnumPropertySearch, operator.PrepareRadianceScene, operator.SetEnumProperty, + operator.AddIESLight, + operator.RemoveIESLight, + operator.SetIESLightObject, prop.RadianceMaterial, + prop.IESLight, prop.BIMSolarProperties, prop.RadianceExporterProperties, ui.BIM_PT_radiance_exporter, ui.BIM_PT_solar, list.MATERIAL_UL_radiance_materials, + list.MATERIAL_UL_ies_lights, ) diff --git a/src/bonsai/bonsai/bim/module/light/list.py b/src/bonsai/bonsai/bim/module/light/list.py index 6eaa1f4c79..57d3060f75 100644 --- a/src/bonsai/bonsai/bim/module/light/list.py +++ b/src/bonsai/bonsai/bim/module/light/list.py @@ -21,9 +21,10 @@ import bpy import json import os from typing import TYPE_CHECKING +from pathlib import Path if TYPE_CHECKING: - from bonsai.bim.module.light.prop import RadianceExporterProperties, RadianceMaterial + 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) @@ -59,3 +60,42 @@ 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 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 + + # Remove button (X icon - negative action) + op = row.operator("radiance.remove_ies_light", text="", icon="X") + op.index = index diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 74e32a02c5..8192dc65f7 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -141,7 +141,32 @@ class PrepareRadianceScene(bpy.types.Operator): 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) + # Get camera up vector + up = camera.matrix_world.to_quaternion() @ Vector((0, 1, 0)) + up.normalize() + + # Ensure up vector is perpendicular to view direction + # If they're parallel, use a different reference vector + if abs(direction.dot(up)) > 0.99: # Nearly parallel + # Use world Y axis as reference, or X axis if that's also parallel + if abs(direction.dot(Vector((0, 1, 0)))) > 0.99: + reference = Vector((1, 0, 0)) + else: + reference = Vector((0, 1, 0)) + # Compute perpendicular up vector using cross product + right = direction.cross(reference) + if right.length < 0.01: # Still parallel, try another reference + reference = Vector((0, 0, 1)) + right = direction.cross(reference) + right.normalize() + up = right.cross(direction) + 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...") @@ -187,9 +212,6 @@ class PrepareRadianceScene(bpy.types.Operator): 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) - 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") print("Setting up camera...") @@ -202,56 +224,71 @@ class PrepareRadianceScene(bpy.types.Operator): 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) + # Get camera position, direction, and up vector + camera_position, camera_direction, camera_up = self.get_camera_data(camera) print(f"Camera position: {camera_position}") print(f"Camera direction: {camera_direction}") + print(f"Camera up: {camera_up}") - # Build datetime for Radiance gensky - # Note: sun_pos_props and sun_props are synchronized by update_sun_path() - dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute) + # Generate sky file based on use_sun setting + if props.use_sun: + # Use sun position data to generate sky + 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"} - print(f"Sun position data for Radiance gensky:") - print(f" DateTime: {dt}") - print(f" Latitude: {sun_props.latitude}°") - print(f" Longitude: {sun_props.longitude}°") - print(f" UTC Zone: {sun_props.UTC_zone}") - print(f" Year: {sun_pos_props.year}") - # Map sky condition enum to boolean parameters - sky_condition = props.sky_condition - sunny_with_sun = sky_condition == "SUNNY_WITH_SUN" - sunny_without_sun = sky_condition == "SUNNY_WITHOUT_SUN" - cloudy = sky_condition == "CLOUDY" + # Build datetime for Radiance gensky + # Note: sun_pos_props and sun_props are synchronized by update_sun_path() + dt = datetime(sun_pos_props.year, sun_props.month, sun_props.day, sun_props.hour, sun_props.minute) - sky_description = pr.gensky( - dt=dt, - latitude=sun_props.latitude, - longitude=sun_props.longitude, - year=sun_pos_props.year, - timezone=-int(sun_props.UTC_zone), - sunny_with_sun=sunny_with_sun, - sunny_without_sun=sunny_without_sun, - cloudy=cloudy, - ground_reflectance=props.ground_reflectance, - turbidity=props.turbidity, - ) + print(f"Sun position data for Radiance gensky:") + print(f" DateTime: {dt}") + print(f" Latitude: {sun_props.latitude}°") + print(f" Longitude: {sun_props.longitude}°") + print(f" UTC Zone: {sun_props.UTC_zone}") + print(f" Year: {sun_pos_props.year}") + # Map sky condition enum to boolean parameters + sky_condition = props.sky_condition + sunny_with_sun = sky_condition == "SUNNY_WITH_SUN" + sunny_without_sun = sky_condition == "SUNNY_WITHOUT_SUN" + cloudy = sky_condition == "CLOUDY" - sky_description_str = sky_description.decode("utf-8") + sky_description = pr.gensky( + dt=dt, + latitude=sun_props.latitude, + longitude=sun_props.longitude, + year=sun_pos_props.year, + timezone=-int(sun_props.UTC_zone), + sunny_with_sun=sunny_with_sun, + sunny_without_sun=sunny_without_sun, + cloudy=cloudy, + ground_reflectance=props.ground_reflectance, + turbidity=props.turbidity, + ) - if use_hdr and choose_hdr_image == "Noon": + sky_description_str = sky_description.decode("utf-8") + else: + # Skip sky generation when use_sun is False + print("Skipping sky generation (use_sun is False)...") + sky_description_str = None - with open(sky_file_path, "w") as f: - f.write(sky_description_str) - f.write("\n") - - f.write( - '''void colorpict env_map + # Only generate sky.rad file when use_sun is True + if props.use_sun: + if use_hdr and choose_hdr_image == "Noon": + with open(sky_file_path, "w") as f: + f.write(sky_description_str) + f.write("\n") + # When using sun, skyfunc is defined by gensky, so we can use it + f.write( + '''void colorpict env_map 7 red green blue "''' - + hdr_image_path - + '''" "''' - + sky_map_cal_path - + '''" map_u map_v + + hdr_image_path + + '''" "''' + + sky_map_cal_path + + '''" map_u map_v 0 1 0.5 @@ -271,10 +308,10 @@ skyfunc colorfunc sky_colour void mixpict composite 7 env_colour sky_colour grey "''' - + hdr_mask_path - + '''" "''' - + sky_map_cal_path - + """" map_u map_v + + hdr_mask_path + + '''" "''' + + sky_map_cal_path + + """" map_u map_v 0 2 0.5 1 @@ -297,16 +334,17 @@ 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") + elif not use_hdr: + with open(sky_file_path, "w") as f: + f.write(sky_description_str) + f.write("\n") + # When using sun, skyfunc is defined by gensky + 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() @@ -345,6 +383,27 @@ ground_glow source ground self.report({"INFO"}, f"Exported Materials Rad file to: {materials_file}") + # Convert IES light files to Radiance format + print("Processing IES light files...") + converted_ies_lights = {} # {index: (rad_path, dat_path)} + for idx, ies_light in enumerate(props.ies_lights): + if not ies_light.is_enabled or not ies_light.ies_file_path or not ies_light.target_object: + continue + try: + rad_file, dat_file = convert_ies_to_radiance( + ies_light.ies_file_path, + output_dir, + lamp_type=ies_light.lamp_type, + lamp_color=ies_light.lamp_color, + multiply_factor=ies_light.multiply_factor, + radius=ies_light.radius, + ) + converted_ies_lights[idx] = (rad_file, dat_file) + print(f" Converted IES light {idx}: {Path(ies_light.ies_file_path).name}") + except Exception as e: + print(f" ERROR converting IES light {idx}: {str(e)}") + self.report({"WARNING"}, f"Failed to convert IES light: {str(e)}") + 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...") @@ -363,9 +422,42 @@ ground_glow source ground 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") + + # Add IES light fixtures + if len(props.ies_lights) > 0: + file.write("\n# IES Light Fixtures\n") + for idx, ies_light in enumerate(props.ies_lights): + if ies_light.ies_file_path and ies_light.target_object: + obj = ies_light.target_object + pos = obj.location + z_rot = ies_light.rotation_z + + if idx in converted_ies_lights: + # Light is enabled and was converted + rad_path = converted_ies_lights[idx][0] + rad_filename = Path(rad_path).name + xform_line = f"!xform -rz {z_rot} -t {pos.x} {pos.y} {pos.z} {rad_filename}\n" + file.write(xform_line) + else: + # Light is disabled or not converted - write as comment + rad_base = Path(ies_light.ies_file_path).stem + rad_filename = f"{rad_base}.rad" + xform_line = f"# !xform -rz {z_rot} -t {pos.x} {pos.y} {pos.z} {rad_filename}\n" + file.write(xform_line) self.report({"INFO"}, "Exported Scene file to: {}".format(scene_file)) + # Validate that at least one light source will be available + has_sky = props.use_sun + has_ies_lights = len(converted_ies_lights) > 0 + + if not has_sky and not has_ies_lights: + error_msg = "ERROR: No light sources available. Please enable 'Use Sun' or add and map IES light fixtures." + self.report({"ERROR"}, error_msg) + print(error_msg) + return {"CANCELLED"} + print("Setting up Radiance scene...") scene = pr.Scene("ascene") @@ -374,7 +466,14 @@ ground_glow source ground scene.add_material(material_path) scene.add_surface(scene_path) - scene.add_source(sky_file_path) + + # Add light source(s) + if props.use_sun: + 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...") assert isinstance(camera.data, bpy.types.Camera) if camera.data.type == "PERSP": @@ -387,7 +486,7 @@ ground_glow source ground aview.type = "v" # Perspective view aview.vp = camera_position aview.vdir = camera_direction - aview.vu = (0, 0, 1) # Assuming Z is up + aview.vu = camera_up # Use computed up vector perpendicular to view direction aview.horiz = math.degrees(camera_fov) aview.vert = math.degrees(vertical_fov) else: # 'ORTHO' @@ -401,7 +500,7 @@ ground_glow source ground aview.type = "l" # Parallel projection (orthographic) aview.vp = camera_position aview.vdir = camera_direction - aview.vu = (0, 0, 1) # Assuming Z is up + aview.vu = camera_up # Use computed up vector perpendicular to view direction aview.horiz = view_width aview.vert = view_height scene.add_view(aview) @@ -470,6 +569,224 @@ class RadianceRender(bpy.types.Operator): return {"FINISHED"} +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" + + def execute(self, context): + props = tool.Blender.get_radiance_exporter_props() + output_dir = props.output_dir + output_file_name = props.output_file_name + + # Check if the main render HDR exists + hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr") + if not os.path.exists(hdr_path): + error_msg = f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first to generate the HDR output." + self.report({"ERROR"}, error_msg) + print(f"ERROR: {error_msg}") + return {"CANCELLED"} + + print(f"Generating false color image from: {hdr_path}") + + # Build false color parameters + fc_scale = str(int(props.false_color_scale)) if props.false_color_scale == int(props.false_color_scale) else str(props.false_color_scale) + + # Determine contour mode + contour_mode = "l" if props.false_color_contour_lines else None + + # Get multiplier based on label selection + if props.false_color_label == "fc": + multiplier = props.false_color_multiplier # Foot-candles default: 16.6295 + elif props.false_color_label == "lux": + multiplier = props.false_color_multiplier if props.false_color_multiplier != 16.629505759940542 else 179.0 + else: # cd/m2 + multiplier = props.false_color_multiplier if props.false_color_multiplier != 16.629505759940542 else 179.0 + + print(f"False color parameters:") + print(f" Label: {props.false_color_label}") + print(f" Scale: {fc_scale}") + print(f" Contour lines: {props.false_color_contour_lines}") + print(f" Multiplier: {multiplier}") + + try: + import subprocess + + # Build command line arguments for falsecolor + # Format: falsecolor [options] -ip input.hdr > output.hdr + fc_output_name = props.false_color_output_name + fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr") + + # Get path to bundled falsecolor executable + light_module_dir = os.path.dirname(__file__) + falsecolor_exe = os.path.join(light_module_dir, "Radiance", "bin", "falsecolor.exe") + radiance_lib = os.path.join(light_module_dir, "Radiance", "lib") + falsecolor_bin = falsecolor_exe if os.path.exists(falsecolor_exe) else "falsecolor" + + cmd = [falsecolor_bin] + + # Add multiplier + cmd.extend(["-m", str(multiplier)]) + + # Add scale + cmd.extend(["-s", fc_scale]) + + # Add label + cmd.extend(["-l", props.false_color_label]) + + # Add contour lines if enabled + if props.false_color_contour_lines: + cmd.append("-cl") + + # Add -ip flag (use same image for intensity and overlay) + cmd.append("-ip") + cmd.append(hdr_path) + + print(f"Running falsecolor command: {' '.join(cmd)}") + print(f"Using falsecolor binary: {falsecolor_bin}") + + # Setup environment for Radiance tools + env = os.environ.copy() + env["RAYPATH"] = "." + os.pathsep + radiance_lib + + # Execute falsecolor and capture output + try: + result = subprocess.run(cmd, capture_output=True, check=True, env=env) + fc_image = result.stdout + + print(f"Saving false color HDR to: {fc_hdr_path}") + with open(fc_hdr_path, "wb") as f: + f.write(fc_image) + + print(f"False color HDR generated successfully: {fc_hdr_path}") + self.report({"INFO"}, f"False color image generated: {fc_hdr_path}") + + except FileNotFoundError: + # falsecolor binary not found in PATH or bundled location + # Fallback: use pcond as an alternative visualization + print("WARNING: falsecolor binary not found") + print(f" Checked locations:") + print(f" 1. Bundled: {falsecolor_exe}") + print(f" 2. System PATH: falsecolor") + print("Using alternative method: Tone-mapped HDR for luminance visualization...") + print("Note: This is a fallback visualization without false color overlay") + + fc_image = pr.pcond(hdr=hdr_path, human=True) + + # Still save as HDR for consistency + with open(fc_hdr_path, "wb") as f: + f.write(fc_image) + + print(f"Tone-mapped HDR saved to: {fc_hdr_path}") + self.report({"WARNING"}, + "falsecolor binary not found. Generated tone-mapped HDR instead. " + "Ensure Radiance/bin folder is present in the light module directory." + ) + + except subprocess.CalledProcessError as e: + error_msg = f"falsecolor failed: {e.stderr.decode() if e.stderr else str(e)}" + print(f"ERROR: {error_msg}") + raise RuntimeError(error_msg) + + # Generate TIFF version for convenience + try: + print("Generating TIFF version of false color image...") + pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True) + fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff") + print(f"Saving false color TIFF to: {fc_tiff_path}") + pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True) + print(f"False color TIFF generated successfully: {fc_tiff_path}") + self.report({"INFO"}, f"False color TIFF also generated: {fc_tiff_path}") + except Exception as e: + print(f"Warning: Failed to generate false color TIFF: {str(e)}") + self.report({"WARNING"}, f"TIFF generation failed: {str(e)}") + + return {"FINISHED"} + + except Exception as e: + error_msg = f"Failed to generate false color image: {str(e)}" + self.report({"ERROR"}, error_msg) + print(f"ERROR: {error_msg}") + print(f"Traceback:") + import traceback + traceback.print_exc() + return {"CANCELLED"} + + +class ConvertHDRToFootCandles(bpy.types.Operator): + """Convert HDR image to foot-candles unit""" + + bl_idname = "render_scene.convert_hdr_to_fc" + bl_label = "Convert HDR to Foot-Candles" + bl_description = "Convert the main render HDR to foot-candles unit for illuminance verification" + + def execute(self, context): + props = tool.Blender.get_radiance_exporter_props() + output_dir = props.output_dir + output_file_name = props.output_file_name + + # Check if the main render HDR exists + hdr_path = os.path.join(output_dir, f"{output_file_name}.hdr") + if not os.path.exists(hdr_path): + error_msg = f"HDR file not found at: {hdr_path}. Please run 'Radiance Render' first." + self.report({"ERROR"}, error_msg) + print(f"ERROR: {error_msg}") + return {"CANCELLED"} + + print(f"Converting HDR to foot-candles: {hdr_path}") + + # Conversion factor from cd/m² to foot-candles + # 1 foot-candle = 10.764 lux = 10.764 cd/m² + # So to convert: multiply by 1/10.764 ≈ 0.0929 + fc_scale_factor = 0.0929005 + + try: + # Use pyradiance Pcomb to convert + print(f"Applying pcomb with scale factor {fc_scale_factor} and original flag...") + + # Create Pcomb command with original=True (-o flag) and scaler for unit conversion + # Note: fout=False ensures the output is a proper HDR file with headers, not raw data + pcomb_result = pr.Pcomb(fout=False).add(hdr_path, original=True, scaler=fc_scale_factor)() + + # Save the converted HDR + fc_output_name = props.hdr_to_fc_output_name + fc_hdr_path = os.path.join(output_dir, f"{fc_output_name}.hdr") + + print(f"Saving foot-candles HDR to: {fc_hdr_path}") + with open(fc_hdr_path, "wb") as f: + f.write(pcomb_result) + + print(f"HDR converted to foot-candles successfully: {fc_hdr_path}") + self.report({"INFO"}, f"HDR converted to foot-candles: {fc_hdr_path}") + + # Also generate TIFF version for convenient viewing + try: + print("Generating TIFF version of foot-candles image...") + pcond_fc_image = pr.pcond(hdr=fc_hdr_path, human=True) + fc_tiff_path = os.path.join(output_dir, f"{fc_output_name}.tiff") + print(f"Saving foot-candles TIFF to: {fc_tiff_path}") + pr.ra_tiff(inp=pcond_fc_image, out=fc_tiff_path, lzw=True) + print(f"TIFF version generated: {fc_tiff_path}") + self.report({"INFO"}, f"TIFF version also generated: {fc_tiff_path}") + except Exception as e: + print(f"Warning: Failed to generate foot-candles TIFF: {str(e)}") + self.report({"WARNING"}, f"TIFF generation failed: {str(e)}") + + print("Foot-candles conversion completed successfully.") + return {"FINISHED"} + + except Exception as e: + error_msg = f"Failed to convert HDR to foot-candles: {str(e)}" + self.report({"ERROR"}, error_msg) + print(f"ERROR: {error_msg}") + print(f"Traceback:") + import traceback + traceback.print_exc() + return {"CANCELLED"} + + def save_obj2mesh_output(inp: Union[bytes, str, Path], output_file: str, **kwargs): try: output_bytes = pr.obj2mesh(inp, **kwargs) @@ -768,3 +1085,141 @@ class SetEnumProperty(bpy.types.Operator): data = context.space_data.context_pointer_get("data") setattr(data, self.prop_name, self.enum_value) return {"FINISHED"} + + +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: + import pyradiance as pr + + ies_path = Path(ies_file_path) + base_name = ies_path.stem + + # ies2rad creates .rad and .dat files + # We pass the output name root (without extension) + output_path = os.path.join(output_dir, base_name) + + # Build ies2rad parameters + 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 + + # Call ies2rad to convert the file + 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 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() + 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"} + + +class SetIESLightObject(bpy.types.Operator): + """Set the target Empty object for an IES light via eyedropper""" + + bl_idname = "radiance.set_ies_light_object" + bl_label = "Set IES Light Object" + bl_options = {"REGISTER", "UNDO"} + + index: bpy.props.IntProperty(description="Index of IES light to set object for") + + def execute(self, context): + if context.object is None or context.object.type != "EMPTY": + self.report({"WARNING"}, "Please select an Empty object") + return {"CANCELLED"} + + props = tool.Blender.get_radiance_exporter_props() + + if 0 <= self.index < len(props.ies_lights): + ies_light = props.ies_lights[self.index] + ies_light.target_object = context.object + props.active_ies_light_index = self.index + self.report({"INFO"}, f"Set target object to: {context.object.name}") + return {"FINISHED"} + + self.report({"WARNING"}, "Invalid IES light index") + return {"CANCELLED"} diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index e0d33971e2..e614ef0cbc 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -227,6 +227,76 @@ class RadianceMaterial(PropertyGroup): color: tuple[float, float, float] +class IESLight(PropertyGroup): + """Represents a mapping between an IES light file and a scene Empty object.""" + + ies_file_path: StringProperty( + name="IES File Path", + description="Path to the IES luminaire data file", + subtype="FILE_PATH", + default="", + ) + 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", + ) + 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, + ) + + if TYPE_CHECKING: + ies_file_path: str + target_object: Union[bpy.types.Object, 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: @@ -415,6 +485,12 @@ 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", @@ -454,6 +530,76 @@ 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=100.0, + default=3.0, + ) + + false_color_contour_lines: BoolProperty( + name="Contour Lines", + description="Add contour lines to the false color image", + default=True, + ) + + 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", + ) + + # HDR to foot-candles conversion properties + convert_hdr_to_fc: BoolProperty( + name="Convert to Foot-Candles", + description="Convert the main render HDR to foot-candles unit for illuminance verification", + default=False, + ) + + hdr_to_fc_output_name: StringProperty( + name="FC Output Name", + description="Name of the foot-candles converted HDR file (without extension)", + default="render_fc", + ) + if TYPE_CHECKING: is_exporting: bool category: str @@ -472,11 +618,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_contour_lines: bool + false_color_multiplier: float + false_color_output_name: str + convert_hdr_to_fc: bool + hdr_to_fc_output_name: str class BIMSolarProperties(PropertyGroup): diff --git a/src/bonsai/bonsai/bim/module/light/ui.py b/src/bonsai/bonsai/bim/module/light/ui.py index 269c587d08..6ddb1e0373 100644 --- a/src/bonsai/bonsai/bim/module/light/ui.py +++ b/src/bonsai/bonsai/bim/module/light/ui.py @@ -142,16 +142,63 @@ class BIM_PT_radiance_exporter(bpy.types.Panel): row = box.row() row.prop(props, "choose_hdr_image") + + layout.separator() # Sky Generation Settings box = layout.box() - box.label(text="Sky Generation Settings") - box.prop(props, "sky_condition") row = box.row() - row.prop(props, "ground_reflectance") + row.prop(props, "use_sun") + if props.use_sun: + box.label(text="Sky Generation Settings") + box.prop(props, "sky_condition") + row = box.row() + row.prop(props, "ground_reflectance") + row = box.row() + row.prop(props, "turbidity") + + + layout.separator() + + # IES Light Fixtures + box = layout.box() + box.label(text="IES Light Fixtures") row = box.row() - row.prop(props, "turbidity") + 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") + + # Properties panel for selected IES light + if len(props.ies_lights) > 0 and props.active_ies_light_index >= 0 and props.active_ies_light_index < len( + props.ies_lights + ): + active_light = props.ies_lights[props.active_ies_light_index] + + box = layout.box() + box.label(text="Selected Light Properties") + + # Rotation Z + row = box.row() + row.prop(active_light, "rotation_z") + + # Lamp Type + row = box.row() + row.prop(active_light, "lamp_type") + + # Lamp Color + row = box.row() + row.prop(active_light, "lamp_color") + + # Brightness Factor + row = box.row() + row.prop(active_light, "multiply_factor") + + # Illum Sphere Radius + row = box.row() + row.prop(active_light, "radius") + + layout.separator() # Step 1: Export geometry for simulation box = layout.box() @@ -176,6 +223,52 @@ class BIM_PT_radiance_exporter(bpy.types.Panel): row.operator("render_scene.radiance", text="Radiance Render") row.enabled = not props.is_exporting + layout.separator() + + # Step 4: Generate false color image + box = layout.box() + box.label(text="Step 4: Generate False Color Image") + row = box.row() + row.prop(props, "use_false_color") + + if props.use_false_color: + row = box.row() + row.prop(props, "false_color_label") + + row = box.row() + row.label(text="Scale Factor") + row.prop(props, "false_color_scale", text="") + + row = box.row() + row.prop(props, "false_color_contour_lines") + + row = box.row() + row.label(text="Multiplier") + row.prop(props, "false_color_multiplier", text="") + + row = box.row() + row.label(text="Output Name") + row.prop(props, "false_color_output_name", text="") + + row = box.row() + row.operator("render_scene.false_color_radiance", text="Generate False Color Image") + + layout.separator() + + # Step 5: Convert to foot-candles + box = layout.box() + box.label(text="Step 5: Convert HDR to Foot-Candles") + row = box.row() + row.prop(props, "convert_hdr_to_fc") + + if props.convert_hdr_to_fc: + row = box.row() + row.label(text="Output Name") + row.prop(props, "hdr_to_fc_output_name", text="") + + row = box.row() + row.operator("render_scene.convert_hdr_to_fc", text="Convert to Foot-Candles") + class BIM_PT_solar(bpy.types.Panel): """Creates a Panel in the render properties window"""