diff --git a/src/bonsai/bonsai/bim/module/bsdd/prop.py b/src/bonsai/bonsai/bim/module/bsdd/prop.py index 1895750387..ea3260909d 100644 --- a/src/bonsai/bonsai/bim/module/bsdd/prop.py +++ b/src/bonsai/bonsai/bim/module/bsdd/prop.py @@ -20,6 +20,7 @@ import bpy import bonsai.tool as tool from bpy.types import PropertyGroup from bonsai.bim.module.bsdd.data import BSDDData +from bonsai.bim.module.classification.data import ClassificationsData from bonsai.bim.prop import Attribute, StrProperty from bpy.props import ( PointerProperty, @@ -42,6 +43,21 @@ def get_active_dictionary(self, context): def update_is_active(self: "BSDDDictionary", context: bpy.types.Context) -> None: BSDDData.data["active_dictionary"] = BSDDData.active_dictionary() + if ClassificationsData.is_loaded: + props = tool.Classification.get_classification_props() + # Preserve original enum value. + classification_source = props.classification_source + ClassificationsData.data["classification_source"] = ClassificationsData.classification_source() + + # Try to restore enum value. + if "classification_source" not in props: + # It's already on the default value, nothing to restore. + return + try: + props.classification_source = classification_source + except TypeError: + # Item is no longer active and not present in enum, fallback to the default. + del props["classification_source"] def update_is_selected(self: "BSDDProperty", context: bpy.types.Context) -> None: @@ -111,7 +127,7 @@ class BSDDPset(PropertyGroup): class BIMBSDDProperties(PropertyGroup): - active_dictionary: StringProperty(name="Active Dictionary") + # TODO: `active_dictionary` is not used anywhere? active_dictionary: EnumProperty(items=get_active_dictionary, name="Active Dictionary") active_uri: StringProperty(name="Active URI") dictionaries: CollectionProperty(name="Dictionaries", type=BSDDDictionary) diff --git a/src/bonsai/bonsai/bim/module/drawing/data.py b/src/bonsai/bonsai/bim/module/drawing/data.py index cfe1492247..65633748ca 100644 --- a/src/bonsai/bonsai/bim/module/drawing/data.py +++ b/src/bonsai/bonsai/bim/module/drawing/data.py @@ -285,7 +285,7 @@ class DecoratorData: return thickness @classmethod - def get_section_markers_display_data(cls, obj): + def get_section_markers_display_data(cls, obj: bpy.types.Object) -> Union[dict[str, Any], None]: """used by IfcAnnotations with ObjectType = "SECTION" """ result = cls.data.get(obj.name, None) if result is not None: @@ -327,10 +327,11 @@ class DecoratorData: return display_data @classmethod - def get_text_data(cls, obj: bpy.types.Object) -> dict: + def get_text_data(cls, obj: bpy.types.Object) -> dict[str, Any]: """used by Ifc Annotations with ObjectType = "TEXT" / "TEXT_LEADER"\n returns font size in mm for current ifc text object""" element = tool.Ifc.get_entity(obj) + assert element props = tool.Drawing.get_text_props(obj) # getting font size pset_data = ifcopenshell.util.element.get_pset(element, "EPset_Annotation") or {} @@ -371,16 +372,18 @@ class DecoratorData: return {"Literals": literals_data, "FontSize": font_size, "Symbol": symbol, "Newline_At": newline_at} @classmethod - def get_dimension_data(cls, obj): + def get_dimension_data(cls, obj: bpy.types.Object) -> dict[str, Any]: """used by Ifc Annotations with ObjectType: DIMENSION / DIAMETER / SECTION_LEVEL / PLAN_LEVEL / RADIUS """ element = tool.Ifc.get_entity(obj) + assert element dimension_style = "arrow" fill_bg = False classes = ifcopenshell.util.element.get_pset(element, "EPset_Annotation", "Classes") if classes: + assert type(classes) is str classes_split = classes.lower().split() if "oblique" in classes_split: dimension_style = "oblique" @@ -406,15 +409,17 @@ class DecoratorData: } @classmethod - def get_fall_data(cls, obj): + def get_fall_data(cls, obj: bpy.types.Object) -> dict[str, Union[str, None]]: object_type = None if element := tool.Ifc.get_entity(obj): object_type = ifcopenshell.util.element.get_predefined_type(element) return {"object_type": object_type} @classmethod - def get_symbol_data(cls, obj): - return tool.Drawing.get_annotation_symbol(tool.Ifc.get_entity(obj)) + def get_symbol_data(cls, obj: bpy.types.Object) -> Union[str, None]: + element = tool.Ifc.get_entity(obj) + assert element + return tool.Drawing.get_annotation_symbol(element) @classmethod def object_decorators(cls, handler): diff --git a/src/bonsai/bonsai/bim/module/drawing/sheeter.py b/src/bonsai/bonsai/bim/module/drawing/sheeter.py index 47bf3181d4..f73b71b64f 100644 --- a/src/bonsai/bonsai/bim/module/drawing/sheeter.py +++ b/src/bonsai/bonsai/bim/module/drawing/sheeter.py @@ -66,7 +66,7 @@ class SheetBuilder: view = ET.SubElement(root, "g") view.attrib["data-type"] = "titleblock" titleblock = ET.SubElement(view, "image") - titleblock.attrib["xlink:href"] = os.path.relpath(titleblock_path, sheet_dir) + titleblock.attrib["xlink:href"] = Path(os.path.relpath(titleblock_path, sheet_dir)).as_posix() titleblock.attrib["x"] = "0" titleblock.attrib["y"] = "0" titleblock.attrib["width"] = str(view_width) diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index 8c5f8ef9ec..bba979af30 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -20,6 +20,7 @@ import bpy from . import ui, prop, operator from bpy.app.handlers import persistent import ifcopenshell.util.element +import math classes = ( operator.AddCurvelikeItem, @@ -76,6 +77,7 @@ classes = ( operator.UpdateItemAttributes, operator.UpdateParametricRepresentation, operator.UpdateRepresentation, + operator.CreateInstance, prop.RepresentationItem, prop.RepresentationItemObject, prop.ShapeAspect, @@ -108,6 +110,7 @@ def block_scale(scene: bpy.types.Scene) -> None: camera = tool.Ifc.get_entity(obj) if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": obj.scale = (-1, -1, -1) + obj.rotation_euler = (0.0, 0.0, math.radians(180)) else: if obj.scale != (1, 1, 1): obj.scale = (1, 1, 1) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index f59467def6..0ed5c4cdd6 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -46,6 +46,7 @@ import bonsai.core.root import bonsai.core.drawing import bonsai.tool as tool import bonsai.bim.handler +from bonsai.bim.module.model.data import AuthoringData from mathutils import Vector, Matrix, Quaternion from time import time from bonsai.bim.ifc import IfcStore @@ -3432,3 +3433,38 @@ class UnassignRepresentationLayer(bpy.types.Operator, tool.Ifc.Operator): layer = ifc_file.by_id(self.layer_id) ifcopenshell.api.layer.unassign_layer(ifc_file, [representation], layer) return {"FINISHED"} + + +class CreateInstance(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.create_instance" + bl_label = "IFC Create Instance" + bl_description = "Create an instance of the type associated with the selected object" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + if not context.selected_objects or len(context.selected_objects) > 1: + self.report({"ERROR"}, "Select exactly one object to create an instance of its type") + return {"CANCELLED"} + + active_obj = context.active_object + element = tool.Ifc.get_entity(active_obj) + if not element: + self.report({"ERROR"}, "Selected object is not an IFC element") + return {"CANCELLED"} + + relating_type = ifcopenshell.util.element.get_type(element) + if not relating_type: + self.report({"ERROR"}, "Selected object has no associated type") + return {"CANCELLED"} + + try: + props = tool.Model.get_model_props() + props.ifc_class = relating_type.is_a() + props.relating_type_id = str(relating_type.id()) + except: + self.report({"ERROR"}, "You must be using the Multiobject Tool or the relevant editing tool") + return {"CANCELLED"} + + bpy.ops.bim.hotkey(hotkey="S_A") + + return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/geometry/ui.py b/src/bonsai/bonsai/bim/module/geometry/ui.py index 8b7e7be43a..f814cd21d3 100644 --- a/src/bonsai/bonsai/bim/module/geometry/ui.py +++ b/src/bonsai/bonsai/bim/module/geometry/ui.py @@ -66,6 +66,12 @@ def object_menu(self, context): self.layout.menu("BIM_MT_object_set_origin", icon="PLUGIN") self.layout.menu("BIM_MT_separate", icon="PLUGIN") + # only show the create instance operator if the current tool is the BIM tool + # if context.space_data and hasattr(context.space_data, "show_object_viewport_mesh"): + # current_tool = context.workspace.tools.from_space_view3d_mode(context.mode, create=False) + # if current_tool and current_tool.idname == "bim.bim_tool": + self.layout.operator("bim.create_instance", icon="PLUGIN") + def edit_mesh_menu(self, context): self.layout.separator() diff --git a/src/bonsai/bonsai/bim/module/light/__init__.py b/src/bonsai/bonsai/bim/module/light/__init__.py index ff44498927..5c3c25ae58 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.MoveSunPathTo3DCursor, operator.RadianceRender, operator.ViewFromSun, + operator.LightPickCoordinates, + operator.LightSetTimeToNow, operator.RefreshIFCMaterials, operator.UnmapMaterial, operator.RADIANCE_OT_select_camera, @@ -54,7 +56,7 @@ classes = ( def register(): - bpy.types.Scene.radiance_exporter_properties = bpy.props.PointerProperty(type=prop.RadianceExporterProperties) + bpy.types.Scene.BIMRadianceExporeterProperies = bpy.props.PointerProperty(type=prop.RadianceExporterProperties) bpy.types.Scene.BIMSolarProperties = bpy.props.PointerProperty(type=prop.BIMSolarProperties) pyradiance_path = Path(get_pyradiance_path()) bin_path = pyradiance_path / "bin" @@ -65,5 +67,5 @@ def register(): def unregister(): - del bpy.types.Scene.radiance_exporter_properties + del bpy.types.Scene.BIMRadianceExporeterProperies del bpy.types.Scene.BIMSolarProperties diff --git a/src/bonsai/bonsai/bim/module/light/decorator.py b/src/bonsai/bonsai/bim/module/light/decorator.py index 127f636919..77cd20ddbd 100644 --- a/src/bonsai/bonsai/bim/module/light/decorator.py +++ b/src/bonsai/bonsai/bim/module/light/decorator.py @@ -22,7 +22,7 @@ import gpu import bmesh import bonsai.tool as tool from bpy.types import SpaceView3D -from math import radians +from math import radians, degrees from mathutils import Vector, Matrix from gpu_extras.batch import batch_for_shader from bpy_extras.view3d_utils import location_3d_to_region_2d @@ -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) @@ -72,11 +72,12 @@ class SolarDecorator: self.draw_text_at_position(context, f"{props.hour:02}:{props.minute:02}", location) self.tn_angle = props.true_north - angle = Matrix.Rotation(radians(self.tn_angle), 4, "Z") + angle = Matrix.Rotation(props.true_north, 4, "Z") 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 @@ -102,12 +104,7 @@ class SolarDecorator: self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) self.line_shader.uniform_float("lineWidth", 2.0) - # general shader - self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") - vertex_shader = """ - uniform mat4 ModelViewProjectionMatrix; - in vec3 pos; void main() { gl_Position = ModelViewProjectionMatrix * vec4(pos, 1.0); @@ -116,8 +113,6 @@ class SolarDecorator: """ fragment_shader = """ - uniform vec4 color; - out vec4 fragColor; void main() { float dist = length(gl_PointCoord - vec2(0.5)); @@ -129,9 +124,16 @@ class SolarDecorator: } """ - self.shader = gpu.types.GPUShader(vertex_shader, fragment_shader) + shader_info = gpu.types.GPUShaderCreateInfo() + shader_info.push_constant("MAT4", "ModelViewProjectionMatrix") + shader_info.vertex_in(0, "VEC3", "pos") + shader_info.vertex_source(vertex_shader) + shader_info.fragment_out(0, "VEC4", "fragColor") + shader_info.push_constant("VEC4", "color") + shader_info.fragment_source(fragment_shader) + self.shader = gpu.shader.create_from_info(shader_info) - props = bpy.context.scene.BIMSolarProperties + props = tool.Blender.get_solar_props() origin = Matrix.Translation(props.sun_path_origin) @@ -153,13 +155,13 @@ class SolarDecorator: # True north self.tn_angle = props.true_north points = [Vector((0, 0, 0)), Vector((0, 0.75, 0))] - angle = Matrix.Rotation(radians(self.tn_angle), 4, "Z") + angle = Matrix.Rotation(self.tn_angle, 4, "Z") points = [origin @ angle @ (v * props.sun_path_size) for v in points] self.draw_batch("POINTS", points, decorator_color_special) verts = [Vector((0, 0, 0)), Vector((0, 0.75, 0))] edges = [[0, 1]] - angle = Matrix.Rotation(radians(self.tn_angle), 4, "Z") + angle = Matrix.Rotation(self.tn_angle, 4, "Z") verts = [origin @ angle @ (v * props.sun_path_size) for v in verts] self.draw_batch("LINES", verts, decorator_color_special, edges) @@ -172,7 +174,7 @@ class SolarDecorator: arc_start = Vector((0, 0.25, 0)) * props.sun_path_size arc_end = angle @ arc_start - angle_half = Matrix.Rotation(radians(self.tn_angle / 2), 4, "Z") + angle_half = Matrix.Rotation(self.tn_angle / 2, 4, "Z") arc_mid = angle_half @ arc_start arc_segments = tool.Cad.create_arc_segments( pts=[origin @ v for v in [arc_start, arc_mid, arc_end]], num_verts=12, make_edges=True diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index 9d02146036..40a68df33b 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -28,8 +28,7 @@ from datetime import datetime import bpy import bonsai.tool as tool from pathlib import Path -from typing import Union, Optional -from collections.abc import Sequence +from typing import Union, TYPE_CHECKING import json import math import time @@ -38,6 +37,8 @@ import ifcopenshell.util.geolocation import webbrowser import ifcopenshell.geom import multiprocessing +import requests +from math import radians from mathutils import Vector from bonsai.bim.module.light.data import SolarData from bpy_extras.io_utils import ExportHelper @@ -58,7 +59,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 +67,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 +88,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 +123,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 +143,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 +175,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 +335,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 +462,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 +479,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,12 +508,12 @@ 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 value = context.TrueNorth.DirectionRatios - props.true_north = ifcopenshell.util.geolocation.yaxis2angle(*value[:2]) + props.true_north = radians(ifcopenshell.util.geolocation.yaxis2angle(*value[:2])) return {"FINISHED"} @@ -521,7 +524,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 +539,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,18 +561,64 @@ 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"} +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"} + + 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 = 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 +666,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 +683,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 +698,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 +724,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..54e5f6f73c 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 ( @@ -40,68 +41,84 @@ from bonsai.bim.module.light.data import SolarData from bonsai.bim.module.light.decorator import SolarDecorator sun_position = tool.Blender.get_sun_position_addon() +now = datetime.datetime.now() 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_coordinates(self: "BIMSolarProperties", context: bpy.types.Context) -> None: + # We define our own `coordinates` property just to ensure changing it would update + # all other props. + # But we still need to set original prop value and retrieve it to ensure coordinate + # was parsed correctly. + sun_props = tool.Blender.get_sun_props() + assert sun_props + sun_props.coordinates = self.coordinates + self["coordinates"] = sun_props.coordinates + self["longitude"] = sun_props.longitude + self["latitude"] = sun_props.latitude update_sun_path(self) -def update_hourminute(self, 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 + self["coordinates"] = sun_props.coordinates update_sun_path(self) -def update_date(self, context): - update_sun_path(self) - - -def update_true_north(self, context): - update_sun_path(self) - - -def update_sun_path_size(self, context): - 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 +126,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", context: Union[bpy.types.Context, None] = None) -> 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 @@ -132,7 +154,7 @@ def update_sun_path(self): sun_props.day = self.day sun_props.time = self.hour + (self.minute / 60) # Preserve IFC sign convention - sun_props.north_offset = radians(self.true_north * -1) + sun_props.north_offset = self.true_north * -1 props.timezone = tzfpy.get_tz(props.longitude, props.latitude) timezone = pytz.timezone(props.timezone) @@ -166,6 +188,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 +197,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 +206,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 +239,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 +256,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 +269,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 +304,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 +316,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,21 +421,60 @@ 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") - latitude: FloatProperty(name="Latitude", min=-90, max=90, update=update_latlong) - longitude: FloatProperty(name="Longitude", min=-180, max=180, update=update_latlong) + coordinates: StringProperty( + name="Coordinates", + description="Latitude and longitude on Earth. Coordinates can be directly entered from an online map", + update=update_coordinates, + default="33°51′54.51″S 151°12′35.64″E", + ) + latitude: FloatProperty( + name="Latitude", + min=-90, + max=90, + update=update_latlong, + default=-33.865143, + ) + longitude: FloatProperty( + name="Longitude", + min=-180, + max=180, + update=update_latlong, + default=151.209900, + ) timezone: StringProperty(name="Timezone", default="Etc/GMT") - true_north: FloatProperty(name="True North", min=-180, max=180, update=update_true_north) - year: IntProperty(name="Year", min=1, max=9999, default=2025, update=update_date) - month: IntProperty(name="Month", min=1, max=12, default=1, update=update_date) - day: IntProperty(name="Date", min=1, max=31, default=1, update=update_date) - hour: IntProperty(name="Hour", min=0, max=23, default=12, update=update_hourminute) - minute: IntProperty(name="Minute", min=0, max=59, update=update_hourminute) + 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) 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_size) + sun_path_size: FloatProperty(name="Sun Path Size", min=0.1, default=50, update=update_sun_path) azimuth: FloatProperty(name="Azimuth") elevation: FloatProperty(name="Elevation") UTC_zone: FloatProperty(name="UTC Zone") @@ -436,3 +506,31 @@ class BIMSolarProperties(PropertyGroup): description="Displays analemmas and sun position", update=update_display_sun_path, ) + + if TYPE_CHECKING: + sites: str + coordinates: 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 + + def set_from_datetime(self, dt: datetime.datetime) -> None: + self.year = dt.year + self.month = dt.month + self.day = dt.day + self.hour = dt.hour + self.minute = dt.minute diff --git a/src/bonsai/bonsai/bim/module/light/ui.py b/src/bonsai/bonsai/bim/module/light/ui.py index 8cbb637e06..35cf36d306 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,22 @@ class BIM_PT_solar(bpy.types.Panel): if not SolarData.is_loaded: SolarData.load() - if (sun_position := SolarData.data["sun_position"]) is None: + assert self.layout + + # Props are more reliable as they'll come and go regardless of Data update. + if (sun_props := tool.Blender.get_sun_props()) is None: self.layout.label(text="Enable 'Sun Position' Add-on To Continue", icon="ERROR") return - props = context.scene.BIMSolarProperties + sun_position = SolarData.data["sun_position"] + if sun_position is None: + # There are props, so there must be an addon. + SolarData.data["sun_position"] = SolarData.sun_position() + + if TYPE_CHECKING: + import sun_position + + props = tool.Blender.get_solar_props() if SolarData.data["sites"]: row = self.layout.row(align=True) @@ -159,10 +170,12 @@ 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 + row = self.layout.row(align=True) + row.alignment = "RIGHT" + row.operator("bim.light_pick_coordinates", icon="URL", text="Pick") - row = self.layout.row() - row.prop(sun_props, "coordinates", icon="URL") + row = self.layout.row(align=True) + row.prop(props, "coordinates", icon="URL") row = self.layout.row(align=True) row.prop(props, "latitude") row.prop(props, "longitude") @@ -172,6 +185,10 @@ class BIM_PT_solar(bpy.types.Panel): if SolarData.data["true_north"] is not None: row.operator("bim.import_true_north", icon="IMPORT", text="") + row = self.layout.row() + row.alignment = "RIGHT" + row.operator("bim.light_set_time_to_now", icon="TIME", text="Now") + row = self.layout.row() row.prop(props, "year") row = self.layout.row(align=True) @@ -243,7 +260,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/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index 31efc65e9f..3ce3319acf 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -610,7 +610,7 @@ class LoadTypeThumbnails(bpy.types.Operator): while queue: # if bpy.app.is_job_running("RENDER_PREVIEW") does not seem to reflect asset preview generation element = queue.pop() - tool.Model.mark_thumbnail_for_update(element) + tool.Model.update_thumbnail_for_element(element) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index f8eb50a38e..000a9a46f4 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -36,6 +36,7 @@ from bonsai.bim.module.model.stair import regenerate_stair_mesh from bonsai.bim.module.model.railing import update_railing_modifier_bmesh from bonsai.bim.module.model.roof import update_roof_modifier_bmesh from collections.abc import Iterable +from typing import Any class BIM_MT_type_manager_menu(bpy.types.Menu): @@ -95,6 +96,7 @@ class LaunchTypeManager(bpy.types.Operator): return context.window_manager.invoke_props_dialog(self, width=550, title="Type Manager", confirm_text="Close") def draw(self, context): + assert self.layout props = tool.Model.get_model_props() row = self.layout.row(align=True) text = f"{AuthoringData.data['total_types']} {AuthoringData.data['ifc_element_type'] or 'Types'}" @@ -135,6 +137,7 @@ class LaunchTypeManager(bpy.types.Operator): flow = self.layout.grid_flow(row_major=True, columns=3, even_columns=True, even_rows=True, align=True) + relating_type: dict[str, Any] for relating_type in AuthoringData.data["paginated_relating_types"]: outer_col = flow.column() box = outer_col.box() diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 90a9849e30..618d0db95b 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -29,7 +29,7 @@ from bpy.types import WorkSpaceTool, Menu from bonsai.bim.module.model.data import AuthoringData, ItemData from bonsai.bim.module.system.data import PortData from bonsai.bim.module.model.prop import get_ifc_class -from typing import Optional, Union +from typing import Optional, Union, Any from functools import partial @@ -506,15 +506,17 @@ class CreateObjectUI: cls.draw_type_manager_launcher(context) @classmethod - def draw_container_info(cls, context): + def draw_container_info(cls, context: bpy.types.Context) -> None: text = AuthoringData.data["default_container"] + assert context.region if context.region.type == "UI": text = f"Container: {text}" cls.layout.row(align=True).label(text=text, icon="OUTLINER_COLLECTION") @classmethod - def draw_type_manager_launcher(cls, context): + def draw_type_manager_launcher(cls, context: bpy.types.Context) -> None: + assert context.region ui_context = context.region.type props = tool.Model.get_model_props() row = cls.layout.row(align=True) @@ -568,7 +570,8 @@ class CreateObjectUI: op.ifc_element_type = AuthoringData.data["ifc_element_type"] @classmethod - def draw_add_object(cls, context): + def draw_add_object(cls, context: bpy.types.Context) -> None: + assert context.region ui_context = str(context.region.type) row = cls.layout.row(align=True) if AuthoringData.data["relating_type_id"]: @@ -580,7 +583,8 @@ class CreateObjectUI: row.label(text="No Construction Type", icon="FILE_3D") @classmethod - def draw_add_object_parameters(cls, context): + def draw_add_object_parameters(cls, context: bpy.types.Context) -> None: + assert context.region ui_context = str(context.region.type) row = cls.layout.row(align=True) if not AuthoringData.data["relating_type_id"]: @@ -633,7 +637,8 @@ class CreateObjectUI: row.prop(data=cls.props, property="rl_mode", text="RL Mode" if ui_context != "TOOL_HEADER" else "RL") @classmethod - def draw_thumbnail(cls, context): + def draw_thumbnail(cls, context: bpy.types.Context) -> None: + assert context.region ui_context = context.region.type row = cls.layout.row(align=True) if not AuthoringData.data["ifc_element_type"]: diff --git a/src/bonsai/bonsai/bim/module/unit/data.py b/src/bonsai/bonsai/bim/module/unit/data.py index bae735245b..22977988bf 100644 --- a/src/bonsai/bonsai/bim/module/unit/data.py +++ b/src/bonsai/bonsai/bim/module/unit/data.py @@ -61,23 +61,21 @@ class UnitsData: return ifcopenshell.util.unit.get_full_unit_name(unit) @classmethod - def unit_classes(cls): + def unit_classes(cls) -> tool.Blender.BLENDER_ENUM_ITEMS: assert (entity := tool.Ifc.schema().declaration_by_name("IfcNamedUnit").as_entity()) declarations = ifcopenshell.util.schema.get_subtypes(entity) version = tool.Ifc.get_schema() + classes = sorted([d.name() for d in declarations]) + ["IfcDerivedUnit", "IfcMonetaryUnit"] results = [ - (c, c, get_entity_doc(version, c).get("description", "")) for c in sorted([d.name() for d in declarations]) + ( + c, + c, + get_entity_doc(version, c).get("description", ""), + tool.Unit.get_icon_for_unit_class(c), + i, + ) + for i, c in enumerate(classes) ] - results.extend( - [ - ("IfcDerivedUnit", "IfcDerivedUnit", get_entity_doc(version, "IfcDerivedUnit").get("description", "")), - ( - "IfcMonetaryUnit", - "IfcMonetaryUnit", - get_entity_doc(version, "IfcMonetaryUnit").get("description", ""), - ), - ] - ) return results @classmethod diff --git a/src/bonsai/bonsai/bim/module/unit/operator.py b/src/bonsai/bonsai/bim/module/unit/operator.py index 0385a0ab4a..19620a89dc 100644 --- a/src/bonsai/bonsai/bim/module/unit/operator.py +++ b/src/bonsai/bonsai/bim/module/unit/operator.py @@ -35,6 +35,7 @@ class AssignSceneUnits(bpy.types.Operator, tool.Ifc.Operator): class AssignUnit(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.assign_unit" bl_label = "Assign Unit" + bl_description = "Assign provided unit as the default project unit for it's unit type." bl_options = {"REGISTER", "UNDO"} unit: bpy.props.IntProperty() @@ -45,6 +46,7 @@ class AssignUnit(bpy.types.Operator, tool.Ifc.Operator): class UnassignUnit(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.unassign_unit" bl_label = "Unassign Unit" + bl_description = "Unassign the specified unit as the project default." bl_options = {"REGISTER", "UNDO"} unit: bpy.props.IntProperty() diff --git a/src/bonsai/bonsai/bim/module/unit/prop.py b/src/bonsai/bonsai/bim/module/unit/prop.py index f0f04e16ae..a335532294 100644 --- a/src/bonsai/bonsai/bim/module/unit/prop.py +++ b/src/bonsai/bonsai/bim/module/unit/prop.py @@ -52,7 +52,6 @@ def get_named_unit_types(self: "BIMUnitProperties", context: bpy.types.Context) class Unit(PropertyGroup): - name: StringProperty(name="Name") unit_type: StringProperty(name="Unit Type") is_assigned: BoolProperty(name="Is Assigned") ifc_class: StringProperty(name="IFC Class") diff --git a/src/bonsai/bonsai/bim/module/unit/ui.py b/src/bonsai/bonsai/bim/module/unit/ui.py index f7b55a96e3..d132a142d9 100644 --- a/src/bonsai/bonsai/bim/module/unit/ui.py +++ b/src/bonsai/bonsai/bim/module/unit/ui.py @@ -16,11 +16,17 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . +from __future__ import annotations import bonsai.bim.helper import bonsai.tool as tool +import bpy from bpy.types import Panel, UIList from bonsai.bim.helper import prop_with_search from bonsai.bim.module.unit.data import UnitsData +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from bonsai.bim.module.unit.prop import BIMUnitProperties, Unit class BIM_PT_units(Panel): @@ -102,15 +108,19 @@ class BIM_PT_units(Panel): class BIM_UL_units(UIList): - def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + def draw_item( + self, + context, + layout: bpy.types.UILayout, + data: BIMUnitProperties, + item: Unit, + icon_: int, + active_data, + active_propname, + ) -> None: props = tool.Unit.get_unit_props() if item: - icon = "MOD_MESHDEFORM" - if item.ifc_class == "IfcSIUnit": - icon = "SNAP_GRID" - elif item.ifc_class == "IfcMonetaryUnit": - icon = "COPY_ID" - + icon = tool.Unit.get_icon_for_unit_class(item.ifc_class) row = layout.row(align=True) row.label(text=item.unit_type or "No Type", icon=icon) row.label(text=item.name or "Unnamed") diff --git a/src/bonsai/bonsai/bim/operator.py b/src/bonsai/bonsai/bim/operator.py index 889a1cffd6..9b55b0c0f0 100644 --- a/src/bonsai/bonsai/bim/operator.py +++ b/src/bonsai/bonsai/bim/operator.py @@ -977,6 +977,8 @@ class BIM_OT_enum_property_search(bpy.types.Operator): def draw(self, context): # Mandatory to access context.data in update : self.layout.context_pointer_set(name="data", data=self.data) + # NOTE: activate_init don't work with prop_search, so cannot activate field for typing, + # though it would fit perfectly. self.layout.prop_search(self, "dummy_name", self, "collection_names") def execute(self, context): diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index c88b16cf9e..0edc1559ca 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -305,7 +305,7 @@ class Drawing: def copy_representation(cls, source, dest): pass def create_annotation_context(cls, target_view, object_type=None): pass def create_annotation_object(cls, drawing, object_type): pass - def create_camera(cls, name, matrix, location_hint): pass + def create_camera(cls, name, matrix, location_hint, target_view): pass def create_svg_schedule(cls, schedule): pass def create_svg_sheet(cls, document, titleblock): pass def delete_collection(cls, collection): pass diff --git a/src/bonsai/bonsai/core/unit.py b/src/bonsai/bonsai/core/unit.py index e9f002b95c..a075c9c5e5 100644 --- a/src/bonsai/bonsai/core/unit.py +++ b/src/bonsai/bonsai/core/unit.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: import bonsai.tool as tool -def assign_scene_units(ifc: tool.Ifc, unit: tool.Unit) -> None: +def assign_scene_units(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> None: if unit.is_scene_unit_metric(): prefix = unit.get_scene_unit_si_prefix("LENGTHUNIT") lengthunit = ifc.run("unit.add_si_unit", unit_type="LENGTHUNIT", prefix=prefix) @@ -44,66 +44,66 @@ def assign_scene_units(ifc: tool.Ifc, unit: tool.Unit) -> None: ifc.run("unit.assign_unit", units=[lengthunit, areaunit, volumeunit, planeangleunit]) -def assign_unit(ifc: tool.Ifc, unit_tool: tool.Unit, unit: ifcopenshell.entity_instance) -> None: +def assign_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: ifc.run("unit.assign_unit", units=[unit]) unit_tool.import_units() -def unassign_unit(ifc: tool.Ifc, unit_tool: tool.Unit, unit: ifcopenshell.entity_instance) -> None: +def unassign_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: ifc.run("unit.unassign_unit", units=[unit]) unit_tool.import_units() -def load_units(unit: tool.Unit) -> None: +def load_units(unit: type[tool.Unit]) -> None: unit.import_units() unit.enable_editing_units() -def disable_unit_editing_ui(unit: tool.Unit) -> None: +def disable_unit_editing_ui(unit: type[tool.Unit]) -> None: unit.disable_editing_units() -def remove_unit(ifc: tool.Ifc, unit_tool: tool.Unit, unit: ifcopenshell.entity_instance) -> None: +def remove_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: ifc.run("unit.remove_unit", unit=unit) unit_tool.import_units() -def add_monetary_unit(ifc: tool.Ifc, unit: tool.Unit) -> ifcopenshell.entity_instance: +def add_monetary_unit(ifc: type[tool.Ifc], unit: type[tool.Unit]) -> ifcopenshell.entity_instance: result = ifc.run("unit.add_monetary_unit") unit.import_units() return result -def add_si_unit(ifc: tool.Ifc, unit: tool.Unit, unit_type: str) -> ifcopenshell.entity_instance: +def add_si_unit(ifc: type[tool.Ifc], unit: type[tool.Unit], unit_type: str) -> ifcopenshell.entity_instance: result = ifc.run("unit.add_si_unit", unit_type=unit_type) unit.import_units() return result def add_context_dependent_unit( - ifc: tool.Ifc, unit: tool.Unit, unit_type: str, name: str + ifc: type[tool.Ifc], unit: type[tool.Unit], unit_type: str, name: str ) -> ifcopenshell.entity_instance: result = ifc.run("unit.add_context_dependent_unit", unit_type=unit_type, name=name) unit.import_units() return result -def add_conversion_based_unit(ifc: tool.Ifc, unit: tool.Unit, name: str) -> ifcopenshell.entity_instance: +def add_conversion_based_unit(ifc: type[tool.Ifc], unit: type[tool.Unit], name: str) -> ifcopenshell.entity_instance: result = ifc.run("unit.add_conversion_based_unit", name=name) unit.import_units() return result -def enable_editing_unit(unit_tool: tool.Unit, unit: ifcopenshell.entity_instance) -> None: +def enable_editing_unit(unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: unit_tool.set_active_unit(unit) unit_tool.import_unit_attributes(unit) -def disable_editing_unit(unit: tool.Unit) -> None: +def disable_editing_unit(unit: type[tool.Unit]) -> None: unit.clear_active_unit() -def edit_unit(ifc: tool.Ifc, unit_tool: tool.Unit, unit: ifcopenshell.entity_instance) -> None: +def edit_unit(ifc: type[tool.Ifc], unit_tool: type[tool.Unit], unit: ifcopenshell.entity_instance) -> None: attributes = unit_tool.export_unit_attributes() if unit_tool.is_unit_class(unit, "IfcMonetaryUnit"): ifc.run("unit.edit_monetary_unit", unit=unit, attributes=attributes) diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index ca45881b55..51989b0a21 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.BIMRadianceExporeterProperies + @classmethod def get_ifc_definition_id(cls, obj: IFC_CONNECTED_TYPE) -> int: if isinstance(obj, bpy.types.Object): diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index b88d9d166a..535bd16b59 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1063,12 +1063,15 @@ class Model(bonsai.core.tool.Model): return # Already processed assert isinstance(obj, bpy.types.Object) - obj.asset_generate_preview() - while not obj.preview: - pass + # Since Blender 4.5 have to use `preview_ensure` instead of `asset_generate_preview`, see #6839. + obj.preview_ensure() - # if object has .data we can use default blender .asset_generate_preview() - if not obj.data: + if obj.data: + # If object has .data we can use default Blender preview. + # No need to preview to update, Blender will do it in background, + # `preview.icon_id` doesn't change after `asset_generate_preview()`. + obj.asset_generate_preview() + else: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) size = 128 img = Image.new("RGBA", (size, size)) diff --git a/src/bonsai/bonsai/tool/unit.py b/src/bonsai/bonsai/tool/unit.py index f296a29500..311a288eca 100644 --- a/src/bonsai/bonsai/tool/unit.py +++ b/src/bonsai/bonsai/tool/unit.py @@ -125,7 +125,7 @@ class Unit(bonsai.core.tool.Unit): props = tool.Unit.get_unit_props() props.units.clear() - units = [] + units: list[ifcopenshell.entity_instance] = [] for unit_class in ["IfcDerivedUnit", "IfcMonetaryUnit", "IfcNamedUnit"]: units += tool.Ifc.get().by_type(unit_class) @@ -206,3 +206,11 @@ class Unit(bonsai.core.tool.Unit): precision = 1e-5 decimal_places = 5 return str(round(precision * round(value / precision), decimal_places)) + + @classmethod + def get_icon_for_unit_class(cls, ifc_class: str) -> str: + if ifc_class == "IfcSIUnit": + return "SNAP_GRID" + elif ifc_class == "IfcMonetaryUnit": + return "COPY_ID" + return "MOD_MESHDEFORM" diff --git a/src/bonsai/scripts/installation/dev_environment.py b/src/bonsai/scripts/installation/dev_environment.py index 7dd32633a2..06df7700c2 100644 --- a/src/bonsai/scripts/installation/dev_environment.py +++ b/src/bonsai/scripts/installation/dev_environment.py @@ -38,14 +38,23 @@ REPO_PATH = r"" # Usually don't need to change, just ensure Blender version matches. BLENDER_PATH = Path.home() / r"AppData/Roaming/Blender Foundation/Blender/4.4" -# BONSAI_PATH: Path to 'bonsai' extension folder inside BLENDER_PATH. -# Need to ensure extensions repo folder in the path below ('raw_githubusercontent_com') matches yours. -# -# Typical scenarios: -# - Bonsai is installed from Bonsai Unstalble Repo - use 'raw_githubusercontent_com' (as it is by default) -# - Bonsai is installed via offline installation - use 'user_default' -# - Bonsai is installed from Blender's official extensions platform - use 'blender_org' -BONSAI_PATH = BLENDER_PATH / r"extensions/raw_githubusercontent_com/bonsai" + +# Determine BONSAI_PATH from existing options +def find_bonsai_path(blender_path): + candidates = [ + blender_path / r"extensions/raw_githubusercontent_com/bonsai", + blender_path / r"extensions/user_default/bonsai", + blender_path / r"extensions/blender_org/bonsai", + ] + for path in candidates: + if path.exists(): + print(f"Found Bonsai at: {path}") + return path + raise FileNotFoundError("Could not find Bonsai path in expected locations.") + + +BONSAI_PATH = find_bonsai_path(BLENDER_PATH) + # --------------------------- diff --git a/src/bonsai/test/bim/feature/drawing.feature b/src/bonsai/test/bim/feature/drawing.feature index a5bcf7e75d..ea7126cb54 100644 --- a/src/bonsai/test/bim/feature/drawing.feature +++ b/src/bonsai/test/bim/feature/drawing.feature @@ -247,8 +247,8 @@ Scenario: Add sheet And I look at the "Sheets" panel And I click "IMPORT" When I click "ADD" - Then the file "{ifc_dir}/layouts/A00 - UNTITLED.svg" should contain "titleblocks/A1.svg" - And the file "{ifc_dir}/layouts/A00 - UNTITLED.svg" should not contain "GRID NORTH" + Then the file "{ifc_dir}/layouts/A01 - UNTITLED.svg" should contain "titleblocks/A1.svg" + And the file "{ifc_dir}/layouts/A01 - UNTITLED.svg" should not contain "GRID NORTH" Scenario: Create sheet Given an empty IFC project @@ -258,8 +258,8 @@ Scenario: Create sheet And I click "ADD" And I select the "UNTITLED" item in the "BIM_UL_sheets" list When I click "OUTPUT" - Then the file "{ifc_dir}/sheets/A00 - UNTITLED.svg" should not contain "titleblocks/A1.svg" - And the file "{ifc_dir}/sheets/A00 - UNTITLED.svg" should contain "GRID NORTH" + Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should not contain "titleblocks/A1.svg" + And the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "GRID NORTH" Scenario: Add drawing to sheet Given an empty IFC project @@ -310,4 +310,4 @@ Scenario: Create sheet - with a drawing added to it And I press "bim.expand_sheet(sheet={sheet})" And I click "IMAGE_PLANE" When I click "OUTPUT" - Then the file "{ifc_dir}/sheets/A00 - UNTITLED.svg" should contain "IfcWall" + Then the file "{ifc_dir}/sheets/A01 - UNTITLED.svg" should contain "IfcWall" diff --git a/src/bonsai/test/bim/feature/light.feature b/src/bonsai/test/bim/feature/light.feature index 04559e4729..f92ff281a6 100644 --- a/src/bonsai/test/bim/feature/light.feature +++ b/src/bonsai/test/bim/feature/light.feature @@ -19,6 +19,8 @@ Scenario: Changing the date And I look at the "Solar Access / Shadow" panel When I set the "Year" property to "2024" And I set the "Date" property to "3" + And I set the "Latitude" property to "0" + And I set the "Longitude" property to "0" Then I see "Sunrise: 06:00:31" Scenario: Changing the time @@ -26,6 +28,8 @@ Scenario: Changing the time And I look at the "Solar Access / Shadow" panel When I set the "Hour" property to "13" And I set the "Minute" property to "30" + And I set the "Latitude" property to "0" + And I set the "Longitude" property to "0" Then I see "Local Time: 13:30:00" Scenario: Automatic timezone detection based on lat / long diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 41e53882a0..aae89f9c76 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -1561,7 +1561,7 @@ def the_file_name_should_contain_value(name, value): @then(parsers.parse('the file "{name}" should not contain "{value}"')) -def the_file_name_should_contain_value(name, value): +def the_file_name_should_not_contain_value(name, value): name = replace_variables(name) with open(name, "r") as f: content = f.read() diff --git a/src/bonsai/test/core/test_drawing.py b/src/bonsai/test/core/test_drawing.py index d0f5902572..ee35fe1a5d 100644 --- a/src/bonsai/test/core/test_drawing.py +++ b/src/bonsai/test/core/test_drawing.py @@ -332,11 +332,11 @@ class TestDisableEditingDrawings: class TestAddDrawing: - def test_run(self, ifc, collector, drawing): + def test_run(self, ifc: Prophecy, collector: Prophecy, drawing: Prophecy): drawing.generate_drawing_name("target_view", "location_hint").should_be_called().will_return("drawing_name") drawing.ensure_unique_drawing_name("drawing_name").should_be_called().will_return("name") drawing.generate_drawing_matrix("target_view", "location_hint").should_be_called().will_return("matrix") - drawing.create_camera("name", "matrix", "location_hint").should_be_called().will_return("obj") + drawing.create_camera("name", "matrix", "location_hint", "target_view").should_be_called().will_return("obj") drawing.get_body_context().should_be_called().will_return("context") drawing.run_root_assign_class( obj="obj", diff --git a/src/bonsai/test/tool/test_context.py b/src/bonsai/test/tool/test_context.py index 7c3c5837d8..7151952d79 100644 --- a/src/bonsai/test/tool/test_context.py +++ b/src/bonsai/test/tool/test_context.py @@ -68,7 +68,7 @@ class TestImportAttributes(test.bim.bootstrap.NewFile): assert props.context_attributes["TargetScale"].float_value == 0.5 assert props.context_attributes["TargetView"].enum_value == "NOTDEFINED" assert props.context_attributes["UserDefinedTargetView"].string_value == "UserDefinedTargetView" - assert not props.context_attributes["Precision"] + assert "Precision" not in props.context_attributes def test_importing_twice(self): ifc = ifcopenshell.file() diff --git a/src/bonsai/test/tool/test_drawing.py b/src/bonsai/test/tool/test_drawing.py index 35d771aef3..f006e5ce2c 100644 --- a/src/bonsai/test/tool/test_drawing.py +++ b/src/bonsai/test/tool/test_drawing.py @@ -492,7 +492,7 @@ class TestGenerateSheetIdentification(NewFile): ifc = ifcopenshell.file() tool.Ifc.set(ifc) assert subject.generate_sheet_identification() == "A01" - document = ifc.createIfcDocumentInformation() + document = ifc.createIfcDocumentInformation(Scope="SHEET") assert subject.generate_sheet_identification() == "A02" @@ -724,7 +724,7 @@ class TestDrawingMaintainingSheetPosition(NewFile): props = tool.Drawing.get_document_props() bpy.ops.bim.create_project() ifc = tool.Ifc.get() - sheet_path = Path.cwd() / "layouts" / "A00 - UNTITLED.svg" + sheet_path = Path.cwd() / "layouts" / "A01 - UNTITLED.svg" bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 4)) obj = bpy.data.objects["Cube"] @@ -832,7 +832,7 @@ class TestUpdateTextValue(NewFile): bpy.ops.bim.edit_text() annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes") assert "title" in annotation_classes - assert DecoratorData.get_ifc_text_data(obj)["FontSize"] == 7.0 + assert DecoratorData.get_text_data(obj)["FontSize"] == 7.0 def test_add_second_literal(self, setup=True): if setup: @@ -869,7 +869,7 @@ class TestUpdateTextValue(NewFile): annotation_classes = ifcopenshell.util.element.get_pset(tool.Ifc.get_entity(obj), "EPset_Annotation", "Classes") assert props.font_size == "7.0" assert "title" in annotation_classes - assert DecoratorData.get_ifc_text_data(obj)["FontSize"] == 7.0 + assert DecoratorData.get_text_data(obj)["FontSize"] == 7.0 # test second literal is present assert props.literals[1].attributes["Literal"].string_value == "test_value" diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py index 9651bfa87e..443d9d0b1c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/unassign_unit.py @@ -22,7 +22,7 @@ from typing import Optional def unassign_unit(file: ifcopenshell.file, units: Optional[list[ifcopenshell.entity_instance]] = None) -> None: """Unassigns units as default units for the project - :param units: A list of units to assign as project defaults. + :param units: A list of units to unassign as project defaults. :return: None Example: diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json b/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json index 1afe8852e4..ea520e028b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json +++ b/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json @@ -31,16 +31,6 @@ "predefined_type": "TOPPING" } ], - "IfcDistributionElement": [ - { - "name": "Furnace" - } - ], - "IfcDistributionElementType": [ - { - "name": "Furnace" - } - ], "IfcElectricDistributionBoard": [ { "name": "Circuit Breaker Panel", @@ -140,6 +130,41 @@ "IfcUnitaryEquipment": [ { "name": "Fan Coil Unit" + }, + { + "name": "Roof Top Unit (RTU)", + "predefined_type": "ROOFTOPUNIT" + }, + { + "name": "Furnace" + }, + { + "name": "Central air conditioner (split or package)", + "predefined_type": "AIRCONDITIONINGUNIT" + }, + { + "name": "Mini-split system", + "predefined_type": "SPLITSYSTEM" + } + ], + "IfcUnitaryEquipmentType": [ + { + "name": "Fan Coil Unit" + }, + { + "name": "Roof Top Unit (RTU)", + "predefined_type": "ROOFTOPUNIT" + }, + { + "name": "Furnace" + }, + { + "name": "Central air conditioner (split or package)", + "predefined_type": "AIRCONDITIONINGUNIT" + }, + { + "name": "Mini-split system", + "predefined_type": "SPLITSYSTEM" } ], "IfcWall": [ diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index b7e91c798b..8d2b2d8306 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -695,12 +695,14 @@ def calculate_unit_scale(ifc_file: ifcopenshell.file, unit_type: str = "LENGTHUN if not (projects := ifc_file.by_type("IfcProject")) or not (units := projects[0].UnitsInContext): return 1 unit_scale = 1 + unit: ifcopenshell.entity_instance for unit in units.Units: - if not hasattr(unit, "UnitType") or unit.UnitType != unit_type: + if getattr(unit, "UnitType", ...) != unit_type: continue while unit.is_a("IfcConversionBasedUnit"): - unit_scale *= unit.ConversionFactor.ValueComponent.wrappedValue - unit = unit.ConversionFactor.UnitComponent + conversion_factor = unit.ConversionFactor + unit_scale *= conversion_factor.ValueComponent.wrappedValue + unit = conversion_factor.UnitComponent if unit.is_a("IfcSIUnit"): unit_scale *= get_prefix_multiplier(unit.Prefix) return unit_scale diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 38708d8018..6fd62f538d 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -694,7 +694,7 @@ def validate_ifc_applications(f: ifcopenshell.file, logger: Union[Logger, json_l app_name: tuple[str, str] = (inst.ApplicationFullName, inst.Version) app_id: str = inst.ApplicationIdentifier - if app_name is not None: + if all(x is not None for x in app_name): if app_name not in used_names: used_names[app_name] = inst else: