diff --git a/src/bonsai/bonsai/bim/module/light/decorator.py b/src/bonsai/bonsai/bim/module/light/decorator.py index 127f636919..9bdeb961e1 100644 --- a/src/bonsai/bonsai/bim/module/light/decorator.py +++ b/src/bonsai/bonsai/bim/module/light/decorator.py @@ -59,10 +59,10 @@ class SolarDecorator: shader.uniform_float("color", color) batch.draw(shader) - def draw_text(self, context): + def draw_text(self, context: bpy.types.Context) -> None: self.addon_prefs = tool.Blender.get_addon_preferences() - props = bpy.context.scene.BIMSolarProperties + props = tool.Blender.get_solar_props() origin = Matrix.Translation(props.sun_path_origin) location = origin @ (props.sun_position * 1.05) @@ -76,7 +76,8 @@ class SolarDecorator: grid_north_p = origin @ angle @ (Vector((0, 0.8, 0)) * props.sun_path_size) self.draw_text_at_position(context, "True North", grid_north_p) - def draw_text_at_position(self, context, text, position): + def draw_text_at_position(self, context: bpy.types.Context, text: str, position: Vector) -> None: + assert context.region and context.region_data coords_2d = location_3d_to_region_2d(context.region, context.region_data, position) if not coords_2d: return @@ -87,7 +88,8 @@ class SolarDecorator: blf.position(self.font_id, co[0], co[1], 0) blf.draw(self.font_id, line) - def draw_geometry(self, context): + def draw_geometry(self, context: bpy.types.Context) -> None: + assert context.region self.addon_prefs = tool.Blender.get_addon_preferences() decorator_color_special = self.addon_prefs.decorator_color_special decorator_color_error = self.addon_prefs.decorator_color_error @@ -131,7 +133,7 @@ class SolarDecorator: self.shader = gpu.types.GPUShader(vertex_shader, fragment_shader) - props = bpy.context.scene.BIMSolarProperties + props = tool.Blender.get_solar_props() origin = Matrix.Translation(props.sun_path_origin) diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 9d02146036..6eb3e2a09c 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -58,7 +58,7 @@ class ExportOBJ(bpy.types.Operator): @classmethod def poll(cls, context): - props = context.scene.radiance_exporter_properties + 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 @@ -66,10 +66,11 @@ class ExportOBJ(bpy.types.Operator): def execute(self, context): # Get the output directory - should_load_from_memory = context.scene.radiance_exporter_properties.should_load_from_memory - output_dir = context.scene.radiance_exporter_properties.output_dir + props = tool.Blender.get_radiance_exporter_props() + should_load_from_memory = props.should_load_from_memory + output_dir = props.output_dir - context.scene.radiance_exporter_properties.is_exporting = True + props.is_exporting = True # Conversion from IFC to OBJ # Settings for obj @@ -86,7 +87,7 @@ class ExportOBJ(bpy.types.Operator): ifc_file = tool.Ifc.get() else: - ifc_file_path = context.scene.radiance_exporter_properties.ifc_file + ifc_file_path = props.ifc_file ifc_file = ifcopenshell.open(ifc_file_path) obj_file_path = os.path.join(output_dir, "model.obj") @@ -121,7 +122,7 @@ class ExportOBJ(bpy.types.Operator): break serialiser.finalize() - context.scene.radiance_exporter_properties.is_exporting = False + props.is_exporting = False self.report({"INFO"}, "Exported OBJ file to: {}".format(obj_file_path)) @@ -141,9 +142,10 @@ class RadianceRender(bpy.types.Operator): return {"CANCELLED"} print("Starting Radiance rendering process...") - props = context.scene.radiance_exporter_properties + 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 @@ -172,8 +174,9 @@ class RadianceRender(bpy.types.Operator): obj_file_path = os.path.join(output_dir, "model.obj") - sun_props = context.scene.BIMSolarProperties - sun_pos_props = context.scene.sun_pos_properties + 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 @@ -331,7 +334,7 @@ ground_glow source ground # 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 = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() data = props.get_mappings_dict() @@ -458,7 +461,7 @@ ground_glow source ground return {"FINISHED"} def get_active_camera(self, context): - props = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() if props.use_active_camera: return context.scene.camera else: @@ -475,8 +478,7 @@ ground_glow source ground return (position.x, position.y, position.z), (direction.x, direction.y, direction.z) def getResolution(self, context): - scene = context.scene - props = scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() resolution_x = props.radiance_resolution_x resolution_y = props.radiance_resolution_y return resolution_x, resolution_y @@ -505,7 +507,7 @@ class ImportTrueNorth(bpy.types.Operator): return SolarData.data["true_north"] is not None def execute(self, context): - props = context.scene.BIMSolarProperties + props = tool.Blender.get_solar_props() for context in tool.Ifc.get().by_type("IfcGeometricRepresentationContext", include_subtypes=False): if not context.TrueNorth: continue @@ -521,7 +523,7 @@ class ImportLatLong(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMSolarProperties + 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) @@ -536,8 +538,9 @@ class MoveSunPathTo3DCursor(bpy.types.Operator): bl_options = {"REGISTER", "UNDO"} def execute(self, context): - props = context.scene.BIMSolarProperties - props.sun_path_origin = bpy.context.scene.cursor.location + props = tool.Blender.get_solar_props() + assert context.scene + props.sun_path_origin = context.scene.cursor.location tool.Blender.update_viewport() return {"FINISHED"} @@ -557,7 +560,7 @@ class ViewFromSun(bpy.types.Operator): camera.data.ortho_scale = 100 # The default of 6m is too small context.scene.collection.objects.link(camera) tool.Blender.activate_camera(camera) - props = context.scene.BIMSolarProperties + props = tool.Blender.get_solar_props() props.hour = props.hour # Just to refresh camera position return {"FINISHED"} @@ -568,7 +571,7 @@ class RefreshIFCMaterials(bpy.types.Operator): bl_description = "Refresh the list of IFC materials for mapping" def execute(self, context): - props = context.scene.radiance_exporter_properties + 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) @@ -616,7 +619,7 @@ class UnmapMaterial(bpy.types.Operator): material_index: bpy.props.IntProperty() def execute(self, context): - props = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() material = props.materials[self.material_index] props.unmap_material(material.name) return {"FINISHED"} @@ -633,7 +636,7 @@ class RADIANCE_OT_select_camera(bpy.types.Operator): return context.object is not None and context.object.type == "CAMERA" def execute(self, context): - props = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() props.selected_camera = context.object props.use_active_camera = False return {"FINISHED"} @@ -648,7 +651,7 @@ class RADIANCE_OT_export_material_mappings(bpy.types.Operator, ExportHelper): filter_glob: bpy.props.StringProperty(default="*.json", options={"HIDDEN"}) def execute(self, context): - props = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() mappings = {} for material in props.materials: @@ -674,7 +677,7 @@ class RADIANCE_OT_import_material_mappings(bpy.types.Operator, ImportHelper): filename_ext = ".json" def execute(self, context): - props = context.scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() props.import_mappings(self.filepath) self.report({"INFO"}, f"Material mappings imported from {self.filepath}") return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index c7b243e367..3e659ea811 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -22,6 +22,7 @@ import tzfpy import json import datetime import bonsai.tool as tool +from typing import TYPE_CHECKING, Literal, Union from math import radians, pi from mathutils import Euler, Vector, Matrix, Quaternion from bpy.props import ( @@ -42,66 +43,78 @@ from bonsai.bim.module.light.decorator import SolarDecorator sun_position = tool.Blender.get_sun_position_addon() with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f: - spectraldb = json.load(f) + spectraldb: dict[str, dict[str, str]] = json.load(f) -def get_sites(self, context): +def get_sites(self: "BIMSolarProperties", context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: if not SolarData.is_loaded: SolarData.load() return SolarData.data["sites"] -def update_latlong(self, context): +def update_latlong(self: "BIMSolarProperties", context: bpy.types.Context) -> None: update_sun_path(self) -def update_hourminute(self, context): +def update_hourminute(self: "BIMSolarProperties", context: bpy.types.Context) -> None: update_sun_path(self) -def update_date(self, context): +def update_date(self: "BIMSolarProperties", context: bpy.types.Context) -> None: update_sun_path(self) -def update_true_north(self, context): +def update_true_north(self: "BIMSolarProperties", context: bpy.types.Context) -> None: update_sun_path(self) -def update_sun_path_size(self, context): +def update_sun_path_size(self: "BIMSolarProperties", context: bpy.types.Context) -> None: update_sun_path(self) -def update_shadow_mode(self, context): +def update_shadow_mode(self: "BIMSolarProperties", context: bpy.types.Context) -> None: + assert context.scene if self.shadow_mode == "SHADING": update_sun_path(self) context.scene.render.engine = "BLENDER_WORKBENCH" + assert context.scene.display + assert context.scene.display.shading context.scene.display.shading.light = "FLAT" context.scene.display.shading.show_shadows = True context.scene.display.shading.show_object_outline = True context.scene.display.shadow_focus = 1.0 + assert context.scene.view_settings context.scene.view_settings.view_transform = "Standard" # Preserve shading colours space = tool.Blender.get_view3d_space() + assert space space.shading.type = "RENDERED" elif self.shadow_mode == "RENDERING": - if context.scene.sun_pos_properties.sun_object is None: + sun_props = tool.Blender.get_sun_props() + assert sun_props + if sun_props.sun_object is None: bpy.ops.object.light_add(type="SUN", radius=1, align="WORLD", location=(0, 0, 0), scale=(1, 1, 1)) bpy.ops.object.move_to_collection(collection_index=0) - context.scene.sun_pos_properties.sun_object = bpy.context.active_object + sun_props.sun_object = bpy.context.active_object update_sun_path(self) context.scene.render.engine = "BLENDER_EEVEE_NEXT" + assert context.scene.display + assert context.scene.display.shading context.scene.display.shading.light = "FLAT" context.scene.display.shading.show_shadows = True context.scene.display.shading.show_object_outline = True context.scene.display.shadow_focus = 1.0 + assert context.scene.view_settings context.scene.view_settings.view_transform = "Standard" # Preserve shading colours space = tool.Blender.get_view3d_space() + assert space space.shading.type = "RENDERED" else: space = tool.Blender.get_view3d_space() + assert space space.shading.type = "SOLID" -def update_display_sun_path(self, context): +def update_display_sun_path(self: "BIMSolarProperties", context: bpy.types.Context) -> None: if self.display_sun_path: update_sun_path(self) SolarDecorator.install(bpy.context) @@ -109,20 +122,25 @@ def update_display_sun_path(self, context): SolarDecorator.uninstall() -def update_resolution(self, context): +def update_resolution(self: "RadianceExporterProperties", context: bpy.types.Context) -> None: + assert context.scene context.scene.render.resolution_x = self.radiance_resolution_x context.scene.render.resolution_y = self.radiance_resolution_y -def update_sun_path(self): +def update_sun_path(self: "BIMSolarProperties") -> None: if not SolarData.is_loaded: SolarData.load() if (sun_position := SolarData.data["sun_position"]) is None: return - props = bpy.context.scene.BIMSolarProperties - sun_props = bpy.context.scene.sun_pos_properties + if TYPE_CHECKING: + import sun_position + + props = tool.Blender.get_solar_props() + sun_props = tool.Blender.get_sun_props() + assert sun_props sun_props.sun_distance = self.sun_path_size sun_props.latitude = self.latitude @@ -166,6 +184,8 @@ def update_sun_path(self): props.elevation = elevation props.UTC_zone = zone + assert bpy.context.scene + assert bpy.context.scene.display if sun_vector.z < 0: bpy.context.scene.display.light_direction = mat @ Vector((0, 0, 1)) else: @@ -173,6 +193,7 @@ def update_sun_path(self): SolarData.data["sun"] = sun_vector if obj := bpy.data.objects.get("SunPathCamera"): + assert isinstance(obj.data, bpy.types.Camera) obj.matrix_world.translation = sun_vector z180_quaternion = Quaternion((0, 0, 1), radians(180)) obj.rotation_mode = "QUATERNION" @@ -181,25 +202,31 @@ def update_sun_path(self): class RadianceMaterial(PropertyGroup): - name: StringProperty(name="Name") style_id: StringProperty(name="Style ID") category: StringProperty(name="Category") subcategory: StringProperty(name="Subcategory") is_mapped: BoolProperty(name="Is Mapped", default=False) color: FloatVectorProperty(name="Color", subtype="COLOR", default=(1.0, 1.0, 1.0), min=0.0, max=1.0, size=3) + if TYPE_CHECKING: + style_id: str + category: str + subcategory: str + is_mapped: bool + color: tuple[float, float, float] + class RadianceExporterProperties(PropertyGroup): - def update_output_dir(self, context): + def update_output_dir(self, context) -> None: if self.output_dir: self.output_dir = bpy.path.abspath(self.output_dir) - def update_ifc_file(self, context): + def update_ifc_file(self, context) -> None: if self.ifc_file: self.ifc_file = bpy.path.abspath(self.ifc_file) - def add_material_mapping(self, style_id, style_name): + def add_material_mapping(self, style_id: str, style_name: str) -> RadianceMaterial: item = self.materials.add() item.name = style_name item.style_id = style_id @@ -208,7 +235,7 @@ class RadianceExporterProperties(PropertyGroup): item.color = (1.0, 1.0, 1.0) # Default white return item - def import_mappings(self, filepath): + def import_mappings(self, filepath: str) -> None: with open(filepath, "r") as f: mappings = json.load(f) @@ -225,10 +252,10 @@ class RadianceExporterProperties(PropertyGroup): new_material.subcategory = mapping["subcategory"] new_material.is_mapped = True - def get_material_mapping(self, style_name): + 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, style_name, category, subcategory): + def set_material_mapping(self, style_id: str, style_name: str, category: str, subcategory: str) -> None: item = self.get_material_mapping(style_name) if item: item.category = category @@ -238,14 +265,14 @@ class RadianceExporterProperties(PropertyGroup): self.materials[-1].category = category self.materials[-1].subcategory = subcategory - def get_mappings_dict(self): + def get_mappings_dict(self) -> dict[str, tuple[str, str]]: return { item.style_id: (item.category, item.subcategory) for item in self.materials if item.category and item.subcategory } - def unmap_material(self, style_name): + def unmap_material(self, style_name: str) -> None: item = self.get_material_mapping(style_name) if item: item.category = "" @@ -273,7 +300,7 @@ class RadianceExporterProperties(PropertyGroup): ("Glass", "Glass", ""), ] - def update_material_mapping(self, context): + def update_material_mapping(self, context: bpy.types.Context) -> None: if self.active_material_index >= 0 and self.active_material_index < len(self.materials): active_material = self.materials[self.active_material_index] active_material.category = self.category @@ -285,7 +312,7 @@ class RadianceExporterProperties(PropertyGroup): items=categories, name="Category", description="Material category", update=update_material_mapping ) - def get_subcategories(self, context): + def get_subcategories(self, context: bpy.types.Context) -> tool.Blender.BLENDER_ENUM_ITEMS: global SUBCATEGORIES_ENUM_ITEMS if self.category in spectraldb: SUBCATEGORIES_ENUM_ITEMS = [(k, k, "") for k in spectraldb[self.category].keys()] @@ -390,6 +417,27 @@ class RadianceExporterProperties(PropertyGroup): poll=lambda self, object: object.type == "CAMERA", ) + if TYPE_CHECKING: + is_exporting: bool + category: str + subcategory: str + materials: bpy.types.bpy_prop_collection_idprop[RadianceMaterial] + active_material_index: int + should_load_from_memory: bool + radiance_resolution_x: int + radiance_resolution_y: int + output_dir: str + ifc_file: str + radiance_quality: Literal["LOW", "MEDIUM", "HIGH"] + radiance_detail: Literal["LOW", "MEDIUM", "HIGH"] + radiance_variability: Literal["LOW", "MEDIUM", "HIGH"] + output_file_name: str + output_file_format: Literal["HDR"] + use_hdr: bool + choose_hdr_image: Literal["Noon"] + use_active_camera: bool + selected_camera: Union[bpy.types.Object, None] + class BIMSolarProperties(PropertyGroup): sites: EnumProperty(items=get_sites, name="Sites") @@ -436,3 +484,23 @@ class BIMSolarProperties(PropertyGroup): description="Displays analemmas and sun position", update=update_display_sun_path, ) + + if TYPE_CHECKING: + sites: str + latitude: float + longitude: float + timezone: str + true_north: float + year: int + month: int + day: int + hour: int + minute: int + sun_position: Vector + sun_path_origin: Vector + sun_path_size: float + azimuth: float + elevation: float + UTC_zone: float + shadow_mode: Literal["NONE", "SHADING", "RENDERING"] + display_sun_path: bool diff --git a/src/bonsai/bonsai/bim/module/light/ui.py b/src/bonsai/bonsai/bim/module/light/ui.py index 8cbb637e06..d3068823da 100644 --- a/src/bonsai/bonsai/bim/module/light/ui.py +++ b/src/bonsai/bonsai/bim/module/light/ui.py @@ -18,6 +18,7 @@ import bpy import bonsai.tool as tool +from typing import TYPE_CHECKING from bonsai.bim.module.light.data import SolarData @@ -33,10 +34,9 @@ class BIM_PT_radiance_exporter(bpy.types.Panel): bl_parent_id = "BIM_PT_tab_lighting" def draw(self, context): + assert self.layout layout = self.layout - scene = context.scene - - props = scene.radiance_exporter_properties + props = tool.Blender.get_radiance_exporter_props() if tool.Ifc.get(): row = self.layout.row() @@ -145,11 +145,15 @@ class BIM_PT_solar(bpy.types.Panel): if not SolarData.is_loaded: SolarData.load() + assert self.layout if (sun_position := SolarData.data["sun_position"]) is None: self.layout.label(text="Enable 'Sun Position' Add-on To Continue", icon="ERROR") return - props = context.scene.BIMSolarProperties + if TYPE_CHECKING: + import sun_position + + props = tool.Blender.get_solar_props() if SolarData.data["sites"]: row = self.layout.row(align=True) @@ -159,7 +163,7 @@ class BIM_PT_solar(bpy.types.Panel): row = self.layout.row(align=True) row.label(text="No Sites With Lat/Longs Found", icon="ERROR") - sun_props = context.scene.sun_pos_properties + sun_props = tool.Blender.get_sun_props() row = self.layout.row() row.prop(sun_props, "coordinates", icon="URL") @@ -243,7 +247,9 @@ class BIM_PT_solar(bpy.types.Panel): row.prop(context.scene.display.shading, "shadow_intensity", text="Shadow Intensity") elif props.shadow_mode == "RENDERING": row = self.layout.row() - row.prop(context.scene.sun_pos_properties.sun_object.data, "energy", text="Sun Intensity") + sun_props = tool.Blender.get_sun_props() + assert sun_props + row.prop(sun_props.sun_object.data, "energy", text="Sun Intensity") row = self.layout.row(align=True) row.operator("bim.view_from_sun", icon="LIGHT_HEMI") diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index ca45881b55..9ab3789510 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -54,13 +54,15 @@ from typing import ( from collections.abc import Iterable, Callable, Generator, Sequence, Sized if TYPE_CHECKING: + from sun_position.properties import SunPosProperties import bpy.stub_internal.rna_enums as rna_enums from bonsai.bim.prop import BIMProperties, BIMObjectProperties from bonsai.bim.module.attribute.prop import BIMAttributeProperties - from bonsai.bim.module.csv.prop import CsvProperties from bonsai.bim.module.constraint.prop import BIMConstraintProperties, BIMObjectConstraintProperties + from bonsai.bim.module.csv.prop import CsvProperties from bonsai.bim.module.diff.prop import DiffProperties from bonsai.bim.module.group.prop import BIMGroupProperties + from bonsai.bim.module.light.prop import BIMSolarProperties, RadianceExporterProperties T = TypeVar("T") @@ -1495,6 +1497,11 @@ class Blender(bonsai.core.tool.Blender): return sun_position + @classmethod + def get_sun_props(cls) -> Union[SunPosProperties, None]: + assert (scene := bpy.context.scene) + return getattr(scene, "sun_pos_properties", None) + @classmethod def scale_font_size(cls, size): default_dpi = 72 @@ -1694,6 +1701,16 @@ class Blender(bonsai.core.tool.Blender): def get_object_attribute_props(cls, obj: bpy.types.Object) -> BIMAttributeProperties: return obj.BIMAttributeProperties + @classmethod + def get_solar_props(cls) -> BIMSolarProperties: + assert (scene := bpy.context.scene) + return scene.BIMSolarProperties + + @classmethod + def get_radiance_exporter_props(cls) -> RadianceExporterProperties: + assert (scene := bpy.context.scene) + return scene.radiance_exporter_properties + @classmethod def get_ifc_definition_id(cls, obj: IFC_CONNECTED_TYPE) -> int: if isinstance(obj, bpy.types.Object):