diff --git a/pyproject.toml b/pyproject.toml index b3f08435e5..0c7914f541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ dependencies = [ "black==25.12", "ruff==0.14.13", "poethepoet", - "gersemi==0.24", + "gersemi==0.25.1", ] [tool.black] diff --git a/src/bonsai/bonsai/bim/helper.py b/src/bonsai/bonsai/bim/helper.py index 1a92078be3..e81c483216 100644 --- a/src/bonsai/bonsai/bim/helper.py +++ b/src/bonsai/bonsai/bim/helper.py @@ -511,14 +511,15 @@ def draw_filter( row.operator("bim.edit_element_filter", icon="CHECKMARK", text="").filter_mode = "EXCLUDE" row.operator("bim.enable_editing_element_filter", icon="CANCEL", text="").filter_mode = "NONE" row = layout.row(align=True) - if not tool.Blender.get_addon_preferences().chain_filter_with_set_operations: + preferences = tool.Blender.get_addon_preferences() + if not preferences.chain_filter_with_set_operations: row.operator("bim.add_filter_group", text="Add Search Group", icon="ADD").module = module else: - if not filter_groups or not any(fg.filters for fg in filter_groups): - op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") - op.type = "entity" - op.index = 0 - op.module = module + row.prop(sprops, "facet", text="") + op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") + op.type = sprops.facet + op.index = 0 + op.module = module op = row.operator("bim.edit_filter_query", text="", icon="FILTER") if "module" in op.bl_rna.properties: op.module = module @@ -526,26 +527,23 @@ def draw_filter( for i, filter_group in enumerate(filter_groups): box = layout.box() - row = box.row(align=True) - row.prop(sprops, "facet", text="") - op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") - op.type = sprops.facet - op.index = i - op.module = module - op = row.operator("bim.remove_filter_group", text="", icon="X") - op.index = i - op.module = module + preferences = tool.Blender.get_addon_preferences() + if not preferences.chain_filter_with_set_operations: + row = box.row(align=True) + row.prop(sprops, "facet", text="") + op = row.operator("bim.add_filter", text="Add Filter", icon="ADD") + op.type = sprops.facet + op.index = i + op.module = module + op = row.operator("bim.remove_filter_group", text="", icon="X") + op.index = i + op.module = module for j, ifc_filter in enumerate(filter_group.filters): if ifc_filter.type == "entity": row = box.row(align=True) preferences = tool.Blender.get_addon_preferences() - if preferences.chain_filter_with_set_operations: - show_mode_toggle = j > 0 - else: - show_mode_toggle = ( - preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 - ) # PR 7315 mode + show_mode_toggle = preferences.chain_filter_with_set_operations and j > 0 if show_mode_toggle: mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} op = row.operator( @@ -761,12 +759,7 @@ def draw_filter( elif ifc_filter.type == "instance": row = box.row(align=True) preferences = tool.Blender.get_addon_preferences() - if preferences.chain_filter_with_set_operations: - show_mode_toggle = j > 0 - else: - show_mode_toggle = ( - preferences.default_filter_with_set_operations_for_globalid_and_class and j > 0 - ) # PR 7315 mode + show_mode_toggle = preferences.chain_filter_with_set_operations and j > 0 if show_mode_toggle: mode_icons = {"ADD": "ADD", "SUBTRACT": "REMOVE", "FILTER": "FILTER"} op = row.operator( diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index 58f6a67065..d8ecfd6894 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -290,7 +290,6 @@ class IfcImporter: self.profile_code("Load linked models") self.add_project_to_scene() self.profile_code("Add project to scene") - self.hide_ifc_spaces() if self.ifc_import_settings.should_clean_mesh and len(self.file.by_type("IfcElement")) < 1000: self.clean_mesh() self.profile_code("Mesh cleaning") @@ -1287,14 +1286,6 @@ class IfcImporter: properties={"Aggregate_Index": aggregate_index, "Name": name}, ) - def hide_ifc_spaces(self): - """Hide IfcSpace objects after they've been added to the scene.""" - for ifc_definition_id, obj in self.added_data.items(): - if isinstance(obj, bpy.types.Object): - element = self.file.by_id(ifc_definition_id) - if element.is_a("IfcSpace"): - obj.hide_set(True) - class IfcImportSettings: """ @@ -1312,7 +1303,7 @@ class IfcImportSettings: self.should_load_geometry = True self.should_clean_mesh = False self.should_cache = True - self.deflection_tolerance = 0.001 + self.deflection_tolerance = 0.05 # Default is 0.001, but I find this to be more practical self.angular_tolerance = 0.5 self.void_limit = 30 self.style_limit = 300 diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py index 14f48d8eec..02cb4bd255 100644 --- a/src/bonsai/bonsai/bim/module/geometry/decorator.py +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -74,7 +74,7 @@ class ItemDecorator: special_verts = [] special_edges = [] - if len(obj.data.loop_triangles) > 0: + if (total_triangles := len(obj.data.loop_triangles)) > 0: verts = [tuple(obj.matrix_world @ v.co) for v in obj.data.vertices] tris = [tuple(t.vertices) for t in obj.data.loop_triangles] diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 47ef5a0343..c758aa7f54 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -140,7 +140,6 @@ classes = ( prop.BIMRailingProperties, prop.BIMRoofProperties, prop.BIMPolylineProperties, - prop.BIMProductPreviewProperties, prop.BIMExternalParametricGeometryProperties, ui.BIM_PT_array, ui.BIM_PT_stair, @@ -263,7 +262,6 @@ def register(): bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties) bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties) - bpy.types.Scene.BIMProductPreviewProperties = bpy.props.PointerProperty(type=prop.BIMProductPreviewProperties) bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties) bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties) bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties) @@ -288,7 +286,6 @@ def unregister(): del bpy.types.Scene.BIMModelProperties del bpy.types.Scene.BIMPolylineProperties - del bpy.types.Scene.BIMProductPreviewProperties del bpy.types.Object.BIMArrayProperties del bpy.types.Object.BIMStairProperties del bpy.types.Object.BIMSverchokProperties diff --git a/src/bonsai/bonsai/bim/module/model/data.py b/src/bonsai/bonsai/bim/module/model/data.py index 2c96e42925..649b92b325 100644 --- a/src/bonsai/bonsai/bim/module/model/data.py +++ b/src/bonsai/bonsai/bim/module/model/data.py @@ -52,6 +52,11 @@ class AuthoringData: cls.is_loaded = True cls.props = tool.Model.get_model_props() cls.data["default_container"] = cls.default_container() + if tool.Ifc.get().schema == "IFC2X3": + if ifc_element_type == "IfcDoorType": + ifc_element_type = "IfcDoorStyle" + elif ifc_element_type == "IfcWindowType": + ifc_element_type = "IfcWindowStyle" cls.data["ifc_element_type"] = ifc_element_type cls.data["ifc_classes"] = cls.ifc_classes() cls.data["ifc_class_current"] = cls.ifc_class_current() diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index aad4c647ee..00e733b397 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -26,16 +26,16 @@ import ifcopenshell import bonsai.tool as tool import math import mathutils -from math import sin, cos, radians +from math import sin, cos, tan, radians from bpy.types import SpaceView3D from bpy_extras import view3d_utils -from mathutils import Vector, Matrix +from mathutils import Vector, Matrix, Quaternion from gpu_extras.batch import batch_for_shader from gpu_extras.presets import draw_circle_2d from typing import Union from bonsai.bim.module.drawing.helper import format_distance from itertools import chain -from typing import Union, Any +from typing import Union, Any, Literal from bpy_extras.view3d_utils import location_3d_to_region_2d @@ -521,6 +521,9 @@ class PolylineDecorator: color = self.addon_prefs.decorations_colour blf.color(self.font_id, *color) + + screen_coords = {} + for i in range(len(self.polyline_points)): if i < 1 and self.measure_type == "POLY_AREA": continue @@ -528,40 +531,32 @@ class PolylineDecorator: continue dim_text_pos = (Vector(self.polyline_points[i].position) + Vector(self.polyline_points[i - 1].position)) / 2 dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - - formatted_value = self.polyline_points[i].dim - - blf.position(self.font_id, dim_text_coords[0], dim_text_coords[1], 0) - text = "d: " + formatted_value - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, dim_text_coords, text_length) - blf.draw(self.font_id, text) + if dim_text_coords: + formatted_value = self.polyline_points[i].dim + text = "d: " + formatted_value + screen_coords[f"distance_{i}"] = (Vector(dim_text_coords), text) if i == 1: continue angle_text_pos = Vector(self.polyline_points[i - 1].position) angle_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, angle_text_pos) - blf.position(self.font_id, angle_text_coords[0], angle_text_coords[1], 0) - text = "a: " + self.polyline_points[i].angle - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, angle_text_coords, text_length) - blf.draw(self.font_id, text) + if angle_text_coords: + text = "a: " + self.polyline_points[i].angle + screen_coords[f"angle_{i}"] = (Vector(angle_text_coords), text) if self.measure_type == "SINGLE": axis_line, axis_line_center = self.calculate_measurement_x_y_and_z(context) for i, dim_text_pos in enumerate(axis_line_center): dim_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, dim_text_pos) - pos = blf.position(self.font_id, dim_text_coords[0], dim_text_coords[1], 0) - value = round((axis_line[i][1] - axis_line[i][0]).length, 4) - direction = axis_line[i][1] - axis_line[i][0] - if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): - value = -value - prefix = "xyz"[i] - formatted_value = tool.Polyline.format_input_ui_units(value) - text = f"{prefix}: {formatted_value}" - text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, dim_text_coords, text_length) - blf.draw(self.font_id, text) + if dim_text_coords: + value = round((axis_line[i][1] - axis_line[i][0]).length, 4) + direction = axis_line[i][1] - axis_line[i][0] + if (i == 0 and direction.x < 0) or (i == 1 and direction.y < 0) or (i == 2 and direction.z < 0): + value = -value + prefix = "xyz"[i] + formatted_value = tool.Polyline.format_input_ui_units(value) + text = f"{prefix}: {formatted_value}" + screen_coords[f"xyz_{i}"] = (Vector(dim_text_coords), text) # Area and Length text polyline_verts = [Vector((p.x, p.y, p.z)) for p in self.polyline_points] @@ -569,35 +564,107 @@ class PolylineDecorator: # Area if self.measure_type == "POLY_AREA" and self.polyline_data.area: if len(polyline_verts) < 3: + blf.disable(self.font_id, blf.SHADOW) return - center = sum(polyline_verts, Vector()) / len(polyline_verts) # Center between all polyline points + center = sum(polyline_verts, Vector()) / len(polyline_verts) if polyline_verts[0] == polyline_verts[-1]: - center = sum(polyline_verts[:-1], Vector()) / len( - polyline_verts[:-1] - ) # Doesn't use the last point if is a closed polyline + center = sum(polyline_verts[:-1], Vector()) / len(polyline_verts[:-1]) area_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, center) - value = self.polyline_data.area - text = f"area: {value}" - text_length = blf.dimensions(self.font_id, text) - area_text_coords[0] -= text_length[0] / 2 # Center text horizontally - blf.position(self.font_id, area_text_coords[0], area_text_coords[1], 0) - self.draw_text_background(context, area_text_coords, text_length) - blf.draw(self.font_id, text) + if area_text_coords: + value = self.polyline_data.area + text = f"area: {value}" + text_length = blf.dimensions(self.font_id, text) + area_text_coords = list(area_text_coords) + area_text_coords[0] -= text_length[0] / 2 + screen_coords["area"] = (Vector(area_text_coords), text) # Length if self.measure_type in {"POLYLINE", "POLY_AREA"}: if len(polyline_verts) < 3: + blf.disable(self.font_id, blf.SHADOW) return total_length_text_coords = view3d_utils.location_3d_to_region_2d(region, rv3d, polyline_verts[-1]) - blf.position(self.font_id, total_length_text_coords[0], total_length_text_coords[1], 0) - value = self.polyline_data.total_length - text = f"length: {value}" + if total_length_text_coords: + value = self.polyline_data.total_length + text = f"length: {value}" + screen_coords["length"] = (Vector(total_length_text_coords), text) + + self.adjust_overlapping_labels(screen_coords) + + for label_key, (screen_co, text) in screen_coords.items(): + blf.position(self.font_id, screen_co.x, screen_co.y, 0) + blf.color(self.font_id, 1, 1, 1, 1) text_length = blf.dimensions(self.font_id, text) - self.draw_text_background(context, total_length_text_coords, text_length) + self.draw_text_background(context, screen_co, text_length) blf.draw(self.font_id, text) blf.disable(self.font_id, blf.SHADOW) + def adjust_overlapping_labels(self, screen_coords): + font_id = self.font_id + text_dimensions = {} + + for label_key, (screen_co, text) in screen_coords.items(): + text_dimensions[label_key] = blf.dimensions(font_id, text) + + if text_dimensions: + first_height = next(iter(text_dimensions.values()))[1] + min_spacing = max(2, first_height * 0.3) + else: + min_spacing = 2 + + label_keys = list(screen_coords.keys()) + for pass_num in range(3): # 3 passes to try to optimize complex overlaps + for i in range(len(label_keys)): + for j in range(i + 1, len(label_keys)): + key1, key2 = label_keys[i], label_keys[j] + co1, _ = screen_coords[key1] + co2, _ = screen_coords[key2] + dim1 = text_dimensions[key1] + dim2 = text_dimensions[key2] + + bounds1 = { + "left": co1.x - min_spacing, + "right": co1.x + dim1[0] + min_spacing, + "top": co1.y + dim1[1] + min_spacing, + "bottom": co1.y - min_spacing, + } + bounds2 = { + "left": co2.x - min_spacing, + "right": co2.x + dim2[0] + min_spacing, + "top": co2.y + dim2[1] + min_spacing, + "bottom": co2.y - min_spacing, + } + + if ( + bounds1["left"] < bounds2["right"] + and bounds1["right"] > bounds2["left"] + and bounds1["bottom"] < bounds2["top"] + and bounds1["top"] > bounds2["bottom"] + ): + x_overlap = min(bounds1["right"], bounds2["right"]) - max(bounds1["left"], bounds2["left"]) + y_overlap = min(bounds1["top"], bounds2["top"]) - max(bounds1["bottom"], bounds2["bottom"]) + + separation_multiplier = 1.25 + + # Move labels in the direction requiring less movement + if x_overlap < y_overlap: + separation_distance = (x_overlap / 2 + min_spacing) * separation_multiplier + if co1.x < co2.x: + co1.x -= separation_distance + co2.x += separation_distance + else: + co1.x += separation_distance + co2.x -= separation_distance + else: + separation_distance = (y_overlap / 2 + min_spacing) * separation_multiplier + if co1.y < co2.y: + co1.y -= separation_distance + co2.y += separation_distance + else: + co1.y += separation_distance + co2.y -= separation_distance + def draw_measurements_poly(self, context): self.shader_config(context) polyline_verts: list[Vector] = [] @@ -879,13 +946,43 @@ class PolylineDecorator: class ProductDecorator: is_installed = False handlers = [] + preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"] relating_type = None + obj_data: dict[str, list] = {} + obj_matrix_i = None @classmethod def install(cls, context): + from bonsai.bim.module.geometry.decorator import ItemDecorator + if cls.is_installed: cls.uninstall() + + props = tool.Model.get_model_props() + handler = cls() + if ( + (props.relating_type_id) + and (relating_type := tool.Ifc.get().by_id(int(props.relating_type_id))) + and (relating_type_obj := tool.Ifc.get_object(relating_type)) + ): + handler.relating_type = relating_type + if tool.Model.get_usage_type(relating_type) == "PROFILE": + if relating_type.is_a() in {"IfcColumnType", "IfcPileType"}: + handler.preview_mode = "PROFILE_VERTICAL" + else: + handler.preview_mode = "PROFILE_HORIZONTAL" + elif tool.Model.get_usage_type(relating_type) == "LAYER2": + handler.preview_mode = "LAYER2" + elif tool.Model.get_usage_type(relating_type) == "LAYER3": + handler.preview_mode = "LAYER3" + else: + handler.preview_mode = "GENERIC" + if relating_type_obj.data: + handler.obj_data = ItemDecorator.get_obj_data(relating_type_obj) + handler.obj_data["raw_verts"] = [Vector(v) for v in handler.obj_data["verts"]] + handler.obj_matrix_i = relating_type_obj.matrix_world.inverted() + cls.handlers.append( SpaceView3D.draw_handler_add(handler.draw_product_preview, (context,), "WINDOW", "POST_VIEW") ) @@ -893,10 +990,6 @@ class ProductDecorator: @classmethod def uninstall(cls): - props = tool.Model.get_product_preview_props() # updated by model/polyline.py - props.verts.clear() - props.edges.clear() - props.tris.clear() for handler in cls.handlers: try: SpaceView3D.draw_handler_remove(handler, "WINDOW") @@ -912,14 +1005,6 @@ class ProductDecorator: shader.uniform_float("color", color) batch.draw(shader) - def get_product_preview_data(self, context) -> dict[str, Any]: - props = tool.Model.get_product_preview_props() - data: dict[str, Any] = {} - data["verts"] = [(*v.value_3d,) for v in props.verts] - data["edges"] = [(int(e.value_2d[0]), int(e.value_2d[1])) for e in props.edges] - data["tris"] = [(int(t.value_3d[0]), int(t.value_3d[1]), int(t.value_3d[2])) for t in props.tris] - return data - def draw_product_preview(self, context): def transparent_color(color, alpha=0.1): color = [i for i in color] @@ -945,12 +1030,549 @@ class ProductDecorator: else: return - product_preview_data = self.get_product_preview_data(context) - if product_preview_data: - self.draw_batch("LINES", product_preview_data["verts"], decorator_color, product_preview_data["edges"]) - self.draw_batch( - "TRIS", product_preview_data["verts"], transparent_color(decorator_color), product_preview_data["tris"] + if self.preview_mode == "LAYER2": + data = self.get_wall_preview_data() + elif self.preview_mode == "LAYER3": + data = self.get_slab_preview_data() + elif self.preview_mode == "PROFILE_VERTICAL": + data = self.get_vertical_profile_preview_data() + elif self.preview_mode == "PROFILE_HORIZONTAL": + data = self.get_horizontal_profile_preview_data() + elif self.preview_mode == "GENERIC": + data = self.get_generic_preview_data() + if data: + self.draw_batch("LINES", data["verts"], decorator_color, data["edges"]) + self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"]) + + def get_wall_preview_data(self): + relating_type = self.relating_type + # Get properties from object type + model_props = tool.Model.get_model_props() + direction_sense = model_props.direction_sense + direction = 1 + if direction_sense == "NEGATIVE": + direction = -1 + + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] + thickness *= direction + + offset_type = model_props.offset_type_vertical + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset = model_props.offset * unit_scale + + height = float(model_props.extrusion_depth) + rl = float(model_props.rl1) + x_angle = float(model_props.x_angle) + if x_angle > radians(90) or x_angle < radians(-90): + height *= -1 + angle_distance = height * tan(x_angle) + thickness *= 1 / cos(x_angle) + + data = {} + data["verts"] = [] + + # Verts + polyline_vertices = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + data = [] + return + for point in polyline_points: + polyline_vertices.append(Vector((point.x, point.y, point.z))) + + is_closed = False + if ( + polyline_vertices[0].x == polyline_vertices[-1].x + and polyline_vertices[0].y == polyline_vertices[-1].y + and polyline_vertices[0].z == polyline_vertices[-1].z + ): + is_closed = True + polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + + bm_base = tool.Model.create_bmesh_from_vertices(polyline_vertices, is_closed) + base_vertices = tool.Cad.offset_edges(bm_base, offset) + offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) + top_vertices = tool.Cad.offset_edges(bm_base, angle_distance + offset) + offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distance + thickness + offset) + if is_closed: + base_vertices.append(base_vertices[0]) + offset_base_verts.append(offset_base_verts[0]) + top_vertices.append(top_vertices[0]) + offset_top_verts.append(offset_top_verts[0]) + + if offset_base_verts is not None: + for v in base_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in offset_base_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in top_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + for v in offset_top_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + bm_base.free() + + # Edges and Tris + points = [] + side_edges_1 = [] + side_edges_2 = [] + base_edges = [] + + for i in range(len(data["verts"])): + points.append(Vector(data["verts"][i])) + + n = len(points) // 2 + bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] + bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] + bottom_connections = [[i, n - i - 1] for i in range(n // 2)] + bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 + side_edges_1.extend(bottom_side_1) + side_edges_2.extend(bottom_side_2) + base_edges.extend(bottom_loop) + + upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] + upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] + upper_loop = [[i + n for i in edges] for edges in bottom_loop] + side_edges_1.extend(upper_side_1) + side_edges_2.extend(upper_side_2) + base_edges.extend(upper_loop) + + loops = [side_edges_1, side_edges_2, base_edges] + + data["edges"] = [] + data["tris"] = [] + for i, group in enumerate(loops): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in points] + new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] + + bm.verts.index_update() + bm.edges.index_update() + + if i == 2: + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) + + bm.verts.index_update() + bm.edges.index_update() + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] + data["edges"].extend(edges) + data["tris"].extend(tris) + + data["edges"] = list(set(tuple(e) for e in data["edges"])) + data["tris"] = list(set(tuple(t) for t in data["tris"])) + return data + + def get_slab_preview_data(self): + relating_type = self.relating_type + model_props = tool.Model.get_model_props() + x_angle = 0 if tool.Cad.is_x(model_props.x_angle, 0, tolerance=0.001) else model_props.x_angle + direction_sense = model_props.direction_sense + direction = 1 + if direction_sense == "NEGATIVE": + direction = -1 + + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] * abs(1 / cos(x_angle)) + thickness *= direction + + offset_type = model_props.offset_type_horizontal + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale + + data = {} + data["verts"] = [] + # Verts + polyline_vertices = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 3: + data = [] + return + for point in polyline_points: + polyline_vertices.append(Vector((point.x, point.y, point.z))) + if x_angle: + # Get vertices relative to the first polyline point as origin + local_vertices = [v - Vector(polyline_vertices[0]) for v in polyline_vertices] + # Make the transformation relative to the x_angle + transformed_vertices = [Vector((v.x, v.y * (1 / cos(x_angle)), v.z)) for v in local_vertices] + # Convert back to world origin + polyline_vertices = [v + Vector(polyline_vertices[0]) for v in transformed_vertices] + if offset != 0: + polyline_vertices = [v + Vector((0, 0, offset)) for v in polyline_vertices] + is_closed = True + if ( + polyline_vertices[0].x == polyline_vertices[-1].x + and polyline_vertices[0].y == polyline_vertices[-1].y + and polyline_vertices[0].z == polyline_vertices[-1].z + ): + polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + bm = tool.Model.create_bmesh_from_vertices(polyline_vertices, is_closed) + bm.verts.ensure_lookup_table() + if x_angle: + rot_mat = Matrix.Rotation(x_angle, 3, "X") + if abs(x_angle) > (pi / 2): + rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0)) + bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat) + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) + new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] + new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, thickness)) + bm.verts.index_update() + bm.edges.index_update() + verts = [tuple(v.co) for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + return data + + def get_vertical_profile_preview_data(self) -> dict[str, Any]: + relating_type = self.relating_type + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = tool.Model.get_model_props() + extrusion_depth = model_props.extrusion_depth + cardinal_point = model_props.cardinal_point + rot_mat = Quaternion() + if relating_type.is_a("IfcBeamType"): + y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) + z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) + rot_mat = y_rot @ z_rot + # Get profile data + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile) + + verts = shape.verts + if not verts: + raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + # Create extrusion bmesh + bm = bmesh.new() + + grouped_verts.append(grouped_verts[0]) # Close profile + new_verts = [bm.verts.new(v) for v in grouped_verts] + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) + + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + + new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True) + new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] + new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) + + bm.verts.index_update() + bm.edges.index_update() + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + + # Calculate rotation, mouse position, angle and cardinal point + polyline_props = tool.Model.get_polyline_props() + snap_prop = polyline_props.snap_mouse_point[0] + mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + data = {} + + verts = [tuple(v.co) for v in bm.verts] + verts = [tuple(rot_mat @ Vector(v)) for v in verts] + verts = [tuple(Vector(v) + mouse_point) for v in verts] + min_z = min(v.co.z for v in bm.verts) + max_z = max(v.co.z for v in bm.verts) + # Add axis verts + verts.append(tuple(mouse_point)) + verts.append(tuple(mouse_point + Vector((0, 0, max_z)))) + # Add only profile edges + edges = [] + for edge in bm.edges: + if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or ( + edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z + ): + edges.append(edge) + # Add axis edge + edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges] + edges.append((len(verts) - 1, len(verts) - 2)) + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + + bm.free() + return data + + def get_horizontal_profile_preview_data(self) -> dict[str, Any]: + relating_type = self.relating_type + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile_curve = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = tool.Model.get_model_props() + cardinal_point = model_props.cardinal_point + + polyline_verts = [] + polyline_props = tool.Model.get_polyline_props() + polyline_data = polyline_props.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + return {} + for point in polyline_points: + polyline_verts.append(Vector((point.x, point.y, point.z))) + polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)] + + # Get profile shape + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile_curve) + + verts = shape.verts + if not verts: + raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile_curve}'.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + data: dict[str, Any] = {} + data["verts"] = [] + data["edges"] = [] + data["tris"] = [] + + grouped_verts = [(v) for v in grouped_verts] + + all_bm = bmesh.new() + for i in range(len(polyline_verts) - 1): + mesh = bpy.data.meshes.new("TempMesh") + # Create the initial mesh from the profile verts + bm = tool.Model.create_bmesh_from_vertices(grouped_verts, is_closed=True) + bm.verts.ensure_lookup_table() + # Creates the clipping plane formed by two segments. + # The first one is for the profile start, based on the current and previous segment of the polyline. + # The second is for the profile end, based on the current and the next segment. + if i == 0: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_start = d + else: + d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized() + d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized() + clip_start = (d1 - d2).normalized() + + if i == len(polyline_verts) - 2: + d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + clip_end = d + else: + d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized() + d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized() + clip_end = (d1 - d2).normalized() + + # Rotates the profile face to the right direction + direction = polyline_verts[i + 1] - polyline_verts[i] + position = polyline_verts[i] + rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4() + bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix) + bmesh.ops.translate(bm, verts=bm.verts, vec=position) + bmesh.ops.translate(bm, verts=bm.verts, vec=-direction) + + # Extrude and move the new face + last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) + new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)] + bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3) + # Apply the cutting planes + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i], + plane_no=clip_start, + clear_inner=True, ) + bm.verts.index_update() + bm.edges.index_update() + cut = bmesh.ops.bisect_plane( + bm, + geom=bm.verts[:] + bm.edges[:] + bm.faces[:], + plane_co=polyline_verts[i + 1], + plane_no=clip_end, + clear_outer=True, + ) + + bm.to_mesh(mesh) + bm.free() + mesh.update() + all_bm.from_mesh(mesh) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh"]) + + # It's necessary to add the mesh to an object to get the expected result. + mesh = bpy.data.meshes.new("TempMesh2") + all_bm.to_mesh(mesh) + all_bm.free() + obj = bpy.data.objects.new("TempObj", mesh) + bm = bmesh.new() + bm.from_mesh(obj.data) + bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"]) + + verts = [tuple(v.co) for v in bm.verts] + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + bm.free() + return data + + def get_generic_preview_data(self): + if not (data := self.obj_data): + return + relating_type = self.relating_type + model_props = tool.Model.get_model_props() + if relating_type.is_a("IfcDoorType"): + rl = float(model_props.rl1) + elif relating_type.is_a("IfcWindowType"): + rl = float(model_props.rl2) + else: + rl = 0 + polyline_props = tool.Model.get_polyline_props() + snap_prop = polyline_props.snap_mouse_point[0] + default_container_elevation = tool.Root.get_default_container_elevation() + mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + snap_obj = bpy.data.objects.get(snap_prop.snap_object) + snap_element = tool.Ifc.get_entity(snap_obj) + rot_mat = Matrix() + if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"): + layers = tool.Model.get_material_layer_parameters(snap_element) + axes = tool.Model.get_wall_axis(snap_obj, layers=layers) + axis_base = axes["base"] + axis_side = axes["side"] + point_on_base_axis = tool.Cad.point_on_edge(mouse_point, axis_base) + point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side) + if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared: + # mouse is snapped to the base axis, the preview looks exactly like the placed door / window + rot_mat = snap_obj.matrix_world + else: + # mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later + rot_mat = ( + (snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180))) + .to_matrix() + .to_4x4() + ) + + mouse_point.z = snap_obj.matrix_world.translation.z + + if snap_element and (container := ifcopenshell.util.element.get_container(snap_element)): + container_obj = tool.Ifc.get_object(container) + mouse_point.z = container_obj.location.z + + obj_type = tool.Ifc.get_object(relating_type) + + subcontexts = tool.Drawing.get_active_drawing_subcontexts() + if not subcontexts: + subcontexts = [("Model", "Body", "MODEL_VIEW")] + + active_context = tool.Geometry.get_active_representation_context(obj_type) + active_context_params = tool.Geometry.get_subcontext_parameters(active_context) + for subcontext in subcontexts: + if subcontext == active_context_params: + break + + representation = ifcopenshell.util.representation.get_representation(relating_type, *subcontext) + if representation: + bonsai.core.geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj_type, + representation, + ) + context.view_layer.update() + break + + translate_mouse = Matrix.Translation(mouse_point) + translate_rl = Matrix.Translation((0.0, 0.0, rl)) + combined_m = translate_mouse @ rot_mat @ translate_rl @ self.obj_matrix_i + data["verts"] = [tuple(combined_m @ v) for v in data["raw_verts"]] + return data class WallAxisDecorator: diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index edd0b8981a..d2514321f3 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -35,563 +35,12 @@ import bonsai.core.root import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool -from math import pi, sin, cos, degrees, tan, radians -from mathutils import Vector, Matrix, Quaternion -from bonsai.bim.module.model.opening import FilledOpeningGenerator +from mathutils import Vector from bonsai.bim.module.model.decorator import PolylineDecorator -from bonsai.bim.module.geometry.decorator import ItemDecorator from typing import Optional, Union, Literal, Any from lark import Lark, Transformer -def create_bmesh_from_vertices(vertices, is_closed=False): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in vertices] - if is_closed: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - new_edges.append( - bm.edges.new((new_verts[-1], new_verts[0])) - ) # Add an edge between the last an first point to make it closed. - else: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - return bm - - -def get_wall_preview_data(context, relating_type): - # Get properties from object type - model_props = tool.Model.get_model_props() - direction_sense = model_props.direction_sense - direction = 1 - if direction_sense == "NEGATIVE": - direction = -1 - - layers = tool.Model.get_material_layer_parameters(relating_type) - if not layers["thickness"]: - return - thickness = layers["thickness"] - thickness *= direction - - offset_type = model_props.offset_type_vertical - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - offset = model_props.offset * unit_scale - - height = float(model_props.extrusion_depth) - rl = float(model_props.rl1) - x_angle = float(model_props.x_angle) - if x_angle > radians(90) or x_angle < radians(-90): - height *= -1 - angle_distance = height * tan(x_angle) - thickness *= 1 / cos(x_angle) - - data = {} - data["verts"] = [] - - # Verts - polyline_vertices = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 2: - data = [] - return - for point in polyline_points: - polyline_vertices.append(Vector((point.x, point.y, point.z))) - - is_closed = False - if ( - polyline_vertices[0].x == polyline_vertices[-1].x - and polyline_vertices[0].y == polyline_vertices[-1].y - and polyline_vertices[0].z == polyline_vertices[-1].z - ): - is_closed = True - polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. - - bm_base = create_bmesh_from_vertices(polyline_vertices, is_closed) - base_vertices = tool.Cad.offset_edges(bm_base, offset) - offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) - top_vertices = tool.Cad.offset_edges(bm_base, angle_distance + offset) - offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distance + thickness + offset) - if is_closed: - base_vertices.append(base_vertices[0]) - offset_base_verts.append(offset_base_verts[0]) - top_vertices.append(top_vertices[0]) - offset_top_verts.append(offset_top_verts[0]) - - if offset_base_verts is not None: - for v in base_vertices: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in offset_base_verts[::-1]: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in top_vertices: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - for v in offset_top_verts[::-1]: - data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - bm_base.free() - - # Edges and Tris - points = [] - side_edges_1 = [] - side_edges_2 = [] - base_edges = [] - - for i in range(len(data["verts"])): - points.append(Vector(data["verts"][i])) - - n = len(points) // 2 - bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] - bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] - bottom_connections = [[i, n - i - 1] for i in range(n // 2)] - bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 - side_edges_1.extend(bottom_side_1) - side_edges_2.extend(bottom_side_2) - base_edges.extend(bottom_loop) - - upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] - upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] - upper_loop = [[i + n for i in edges] for edges in bottom_loop] - side_edges_1.extend(upper_side_1) - side_edges_2.extend(upper_side_2) - base_edges.extend(upper_loop) - - loops = [side_edges_1, side_edges_2, base_edges] - - data["edges"] = [] - data["tris"] = [] - for i, group in enumerate(loops): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in points] - new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] - - bm.verts.index_update() - bm.edges.index_update() - - if i == 2: - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) - - bm.verts.index_update() - bm.edges.index_update() - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] - data["edges"].extend(edges) - data["tris"].extend(tris) - - data["edges"] = list(set(tuple(e) for e in data["edges"])) - data["tris"] = list(set(tuple(t) for t in data["tris"])) - - return data - - -def get_slab_preview_data(context, relating_type): - model_props = tool.Model.get_model_props() - x_angle = 0 if tool.Cad.is_x(model_props.x_angle, 0, tolerance=0.001) else model_props.x_angle - direction_sense = model_props.direction_sense - direction = 1 - if direction_sense == "NEGATIVE": - direction = -1 - - layers = tool.Model.get_material_layer_parameters(relating_type) - if not layers["thickness"]: - return - thickness = layers["thickness"] * abs(1 / cos(x_angle)) - thickness *= direction - - offset_type = model_props.offset_type_horizontal - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - offset = model_props.offset * abs(1 / cos(x_angle)) * unit_scale - - data = {} - data["verts"] = [] - # Verts - polyline_vertices = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 3: - data = [] - return - for point in polyline_points: - polyline_vertices.append(Vector((point.x, point.y, point.z))) - if x_angle: - # Get vertices relative to the first polyline point as origin - local_vertices = [v - Vector(polyline_vertices[0]) for v in polyline_vertices] - # Make the transformation relative to the x_angle - transformed_vertices = [Vector((v.x, v.y * (1 / cos(x_angle)), v.z)) for v in local_vertices] - # Convert back to world origin - polyline_vertices = [v + Vector(polyline_vertices[0]) for v in transformed_vertices] - if offset != 0: - polyline_vertices = [v + Vector((0, 0, offset)) for v in polyline_vertices] - is_closed = True - if ( - polyline_vertices[0].x == polyline_vertices[-1].x - and polyline_vertices[0].y == polyline_vertices[-1].y - and polyline_vertices[0].z == polyline_vertices[-1].z - ): - polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. - bm = create_bmesh_from_vertices(polyline_vertices, is_closed) - bm.verts.ensure_lookup_table() - if x_angle: - rot_mat = Matrix.Rotation(x_angle, 3, "X") - if abs(x_angle) > (pi / 2): - rot_mat = rot_mat @ Matrix.Scale(-1, 3, (0, 1, 0)) - bmesh.ops.rotate(bm, cent=Vector(bm.verts[0].co), verts=bm.verts, matrix=rot_mat) - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) - new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] - new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, thickness)) - bm.verts.index_update() - bm.edges.index_update() - verts = [tuple(v.co) for v in bm.verts] - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - return data - - -def get_vertical_profile_preview_data( - context: bpy.types.Context, relating_type: ifcopenshell.entity_instance -) -> dict[str, Any]: - material = ifcopenshell.util.element.get_material(relating_type) - try: - profile = material.MaterialProfiles[0].Profile - except: - return {} - - model_props = tool.Model.get_model_props() - extrusion_depth = model_props.extrusion_depth - cardinal_point = model_props.cardinal_point - rot_mat = Quaternion() - if relating_type.is_a("IfcBeamType"): - y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) - z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) - rot_mat = y_rot @ z_rot - # Get profile data - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, profile) - - verts = shape.verts - if not verts: - raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile}'.") - - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - # Create offsets based on cardinal point - min_x = min(v[0] for v in grouped_verts) - max_x = max(v[0] for v in grouped_verts) - min_y = min(v[1] for v in grouped_verts) - max_y = max(v[1] for v in grouped_verts) - - x_offset = (max_x - min_x) / 2 - y_offset = (max_y - min_y) / 2 - - match cardinal_point: - case "1": - grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "2": - grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] - case "3": - grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "4": - grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] - case "5": - grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] - case "6": - grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] - case "7": - grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - case "8": - grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] - case "9": - grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - - # Create extrusion bmesh - bm = bmesh.new() - - grouped_verts.append(grouped_verts[0]) # Close profile - new_verts = [bm.verts.new(v) for v in grouped_verts] - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) - - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - - new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True) - new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] - new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) - - bm.verts.index_update() - bm.edges.index_update() - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - - # Calculate rotation, mouse position, angle and cardinal point - polyline_props = tool.Model.get_polyline_props() - snap_prop = polyline_props.snap_mouse_point[0] - mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - data = {} - - verts = [tuple(v.co) for v in bm.verts] - verts = [tuple(rot_mat @ Vector(v)) for v in verts] - verts = [tuple(Vector(v) + mouse_point) for v in verts] - min_z = min(v.co.z for v in bm.verts) - max_z = max(v.co.z for v in bm.verts) - # Add axis verts - verts.append(tuple(mouse_point)) - verts.append(tuple(mouse_point + Vector((0, 0, max_z)))) - # Add only profile edges - edges = [] - for edge in bm.edges: - if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or ( - edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z - ): - edges.append(edge) - # Add axis edge - edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges] - edges.append((len(verts) - 1, len(verts) - 2)) - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - - bm.free() - - return data - - -def get_horizontal_profile_preview_data( - context: bpy.types.Context, relating_type: ifcopenshell.entity_instance -) -> dict[str, Any]: - material = ifcopenshell.util.element.get_material(relating_type) - try: - profile_curve = material.MaterialProfiles[0].Profile - except: - return {} - - model_props = tool.Model.get_model_props() - cardinal_point = model_props.cardinal_point - - polyline_verts = [] - polyline_props = tool.Model.get_polyline_props() - polyline_data = polyline_props.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 2: - return {} - for point in polyline_points: - polyline_verts.append(Vector((point.x, point.y, point.z))) - polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)] - - # Get profile shape - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, profile_curve) - - verts = shape.verts - if not verts: - raise RuntimeError(f"Profile shape has no vertices, it probably is invalid: '{profile_curve}'.") - - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - # Create offsets based on cardinal point - min_x = min(v[0] for v in grouped_verts) - max_x = max(v[0] for v in grouped_verts) - min_y = min(v[1] for v in grouped_verts) - max_y = max(v[1] for v in grouped_verts) - - x_offset = (max_x - min_x) / 2 - y_offset = (max_y - min_y) / 2 - - match cardinal_point: - case "1": - grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "2": - grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] - case "3": - grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "4": - grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] - case "5": - grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] - case "6": - grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] - case "7": - grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - case "8": - grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] - case "9": - grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - - data: dict[str, Any] = {} - data["verts"] = [] - data["edges"] = [] - data["tris"] = [] - - grouped_verts = [(v) for v in grouped_verts] - - all_bm = bmesh.new() - for i in range(len(polyline_verts) - 1): - mesh = bpy.data.meshes.new("TempMesh") - # Create the initial mesh from the profile verts - bm = create_bmesh_from_vertices(grouped_verts, is_closed=True) - bm.verts.ensure_lookup_table() - # Creates the clipping plane formed by two segments. - # The first one is for the profile start, based on the current and previous segment of the polyline. - # The second is for the profile end, based on the current and the next segment. - if i == 0: - d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - clip_start = d - else: - d1 = (polyline_verts[i] - polyline_verts[i - 1]).normalized() - d2 = (polyline_verts[i] - polyline_verts[i + 1]).normalized() - clip_start = (d1 - d2).normalized() - - if i == len(polyline_verts) - 2: - d = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - clip_end = d - else: - d1 = (polyline_verts[i + 1] - polyline_verts[i]).normalized() - d2 = (polyline_verts[i + 1] - polyline_verts[i + 2]).normalized() - clip_end = (d1 - d2).normalized() - - # Rotates the profile face to the right direction - direction = polyline_verts[i + 1] - polyline_verts[i] - position = polyline_verts[i] - rotation_matrix = direction.to_track_quat("Z", "Y").to_matrix().to_4x4() - bmesh.ops.transform(bm, verts=bm.verts, matrix=rotation_matrix) - bmesh.ops.translate(bm, verts=bm.verts, vec=position) - bmesh.ops.translate(bm, verts=bm.verts, vec=-direction) - - # Extrude and move the new face - last_face = bmesh.ops.extrude_face_region(bm, geom=bm.edges[:] + bm.faces[:]) - new_verts = [e for e in last_face["geom"] if isinstance(e, bmesh.types.BMVert)] - bmesh.ops.translate(bm, verts=new_verts, vec=direction * 3) - # Apply the cutting planes - cut = bmesh.ops.bisect_plane( - bm, - geom=bm.verts[:] + bm.edges[:] + bm.faces[:], - plane_co=polyline_verts[i], - plane_no=clip_start, - clear_inner=True, - ) - bm.verts.index_update() - bm.edges.index_update() - cut = bmesh.ops.bisect_plane( - bm, - geom=bm.verts[:] + bm.edges[:] + bm.faces[:], - plane_co=polyline_verts[i + 1], - plane_no=clip_end, - clear_outer=True, - ) - - bm.to_mesh(mesh) - bm.free() - mesh.update() - all_bm.from_mesh(mesh) - bpy.data.meshes.remove(bpy.data.meshes["TempMesh"]) - - # It's necessary to add the mesh to an object to get the expected result. - mesh = bpy.data.meshes.new("TempMesh2") - all_bm.to_mesh(mesh) - all_bm.free() - obj = bpy.data.objects.new("TempObj", mesh) - bm = bmesh.new() - bm.from_mesh(obj.data) - bpy.data.meshes.remove(bpy.data.meshes["TempMesh2"]) - - verts = [tuple(v.co) for v in bm.verts] - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - data["verts"] = verts - data["edges"] = edges - data["tris"] = tris - bm.free() - return data - - -def get_generic_product_preview_data(context, relating_type): - model_props = tool.Model.get_model_props() - if relating_type.is_a("IfcDoorType"): - rl = float(model_props.rl1) - elif relating_type.is_a("IfcWindowType"): - rl = float(model_props.rl2) - else: - rl = 0 - polyline_props = tool.Model.get_polyline_props() - snap_prop = polyline_props.snap_mouse_point[0] - default_container_elevation = tool.Root.get_default_container_elevation() - mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) - snap_obj = bpy.data.objects.get(snap_prop.snap_object) - snap_element = tool.Ifc.get_entity(snap_obj) - rot_mat = Quaternion() - if relating_type.is_a() in ["IfcDoorType", "IfcWindowType"] and snap_element and snap_element.is_a("IfcWall"): - layers = tool.Model.get_material_layer_parameters(snap_element) - axes = tool.Model.get_wall_axis(snap_obj, layers=layers) - axis_base = axes["base"] - axis_side = axes["side"] - point_on_base_axis = tool.Cad.point_on_edge(mouse_point, axis_base) - point_on_side_axis = tool.Cad.point_on_edge(mouse_point, axis_side) - if (point_on_base_axis - mouse_point).length_squared <= (point_on_side_axis - mouse_point).length_squared: - # mouse is snapped to the base axis, the preview looks exactly like the placed door / window - rot_mat = snap_obj.matrix_world.to_quaternion() - else: - # mouse is snapped to the side axis, the preview is inverted, rotate it now and correct x position later - rot_mat = snap_obj.matrix_world.to_quaternion() @ Quaternion(Vector((0, 0, 1)), radians(180)) - - mouse_point.z = snap_obj.matrix_world.translation.z - - if snap_element and (container := ifcopenshell.util.element.get_container(snap_element)): - container_obj = tool.Ifc.get_object(container) - mouse_point.z = container_obj.location.z - - obj_type = tool.Ifc.get_object(relating_type) - - subcontexts = tool.Drawing.get_active_drawing_subcontexts() - if not subcontexts: - subcontexts = [("Model", "Body", "MODEL_VIEW")] - - active_context = tool.Geometry.get_active_representation_context(obj_type) - active_context_params = tool.Geometry.get_subcontext_parameters(active_context) - for subcontext in subcontexts: - if subcontext == active_context_params: - break - - representation = ifcopenshell.util.representation.get_representation(relating_type, *subcontext) - if representation: - bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj_type, - representation, - ) - context.view_layer.update() - break - - if obj_type.data: - data = ItemDecorator.get_obj_data(obj_type) - data["verts"] = [tuple(obj_type.matrix_world.inverted() @ Vector(v)) for v in data["verts"]] - data["verts"] = [tuple(rot_mat @ (Vector((v[0], v[1], (v[2] + rl)))) + mouse_point) for v in data["verts"]] - - return data - - class PolylineOperator: # TODO Fill doc strings """ """ @@ -987,37 +436,6 @@ class PolylineOperator: tool.Blender.update_viewport() return {"RUNNING_MODAL"} - def get_product_preview_data(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: - if tool.Model.get_usage_type(relating_type) == "PROFILE": - if relating_type.is_a() in {"IfcColumnType", "IfcPileType"}: - data = get_vertical_profile_preview_data(context, relating_type) - else: - data = get_horizontal_profile_preview_data(context, relating_type) - elif tool.Model.get_usage_type(relating_type) == "LAYER2": - data = get_wall_preview_data(context, relating_type) - elif tool.Model.get_usage_type(relating_type) == "LAYER3": - data = get_slab_preview_data(context, relating_type) - else: - data = get_generic_product_preview_data(context, relating_type) - - # Update properties so it can be used by the decorator - props = tool.Model.get_product_preview_props() - props.verts.clear() - props.edges.clear() - props.tris.clear() - if not data: - return - - for vert in data["verts"]: - v = props.verts.add() - v.value_3d = vert - for edge in data["edges"]: - e = props.edges.add() - e.value_2d = edge - for tri in data["tris"]: - t = props.tris.add() - t.value_3d = tri - def set_offset(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_instance) -> None: props = tool.Model.get_model_props() direction_sense = props.direction_sense diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index c08ff9131b..191a9406fe 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -249,8 +249,6 @@ class DrawOccurrence(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): if event.value == "RELEASE" and event.type == "LEFTMOUSE": self.create_occurrence(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index ecc1b86163..80c7ccc20e 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -1189,11 +1189,8 @@ class DrawPolylineProfile(bpy.types.Operator, PolylineOperator, tool.Ifc.Operato return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index b26466aa15..4c3466d325 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -1697,17 +1697,6 @@ class ProductPreviewItem(PropertyGroup): value_2d: tuple[float, float] -class BIMProductPreviewProperties(PropertyGroup): - verts: bpy.props.CollectionProperty(type=ProductPreviewItem) - edges: bpy.props.CollectionProperty(type=ProductPreviewItem) - tris: bpy.props.CollectionProperty(type=ProductPreviewItem) - - if TYPE_CHECKING: - verts: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - edges: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - tris: bpy.types.bpy_prop_collection_idprop[ProductPreviewItem] - - def update_is_editing(self: "BIMExternalParametricGeometryProperties", context: bpy.types.Context) -> None: if self.is_editing: return diff --git a/src/bonsai/bonsai/bim/module/model/slab.py b/src/bonsai/bonsai/bim/module/model/slab.py index 4c9e89bf41..b6046df18d 100644 --- a/src/bonsai/bonsai/bim/module/model/slab.py +++ b/src/bonsai/bonsai/bim/module/model/slab.py @@ -1135,11 +1135,8 @@ class DrawPolylineSlab(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 86beea6079..0d9aea58cd 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -691,9 +691,17 @@ class BIM_PT_external_parametric_geometry(bpy.types.Panel): # should find a way to update only on graph changes. res = tool.Model.update_mesh_from_sverchok(obj, props.sverchok_nodes) if res is not None: - print(res) layout.label(text=f"Error Updating from Graph, See System Console", icon="ERROR") + layout.label(text="Parameters:") + box = layout.box() + + group_node = tool.Model.get_ifcsverchok_group_node(props.sverchok_nodes) + node_tree = group_node.node_tree + + for socket, interface_socket in zip(group_node.inputs, node_tree.sockets("INPUT")): + socket.draw_group_property(box, socket.name, interface_socket) + def draw_door_properties(layout: bpy.types.UILayout, props: module_prop.BIMDoorProperties) -> None: """Draw door properties UI (shared between properties panel and preferences).""" diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index d652041125..d343bc75ae 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -787,11 +787,8 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator, tool.Ifc.Operator): return {"FINISHED"} self.handle_keyboard_input(context, event) - self.handle_inserting_polyline(context, event) - self.get_product_preview_data(context, self.relating_type) - cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/project/prop.py b/src/bonsai/bonsai/bim/module/project/prop.py index 8983c927c9..6894392437 100644 --- a/src/bonsai/bonsai/bim/module/project/prop.py +++ b/src/bonsai/bonsai/bim/module/project/prop.py @@ -319,7 +319,7 @@ class BIMProjectProperties(PropertyGroup): ), default=False, ) - deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.001) + deflection_tolerance: FloatProperty(name="Deflection Tolerance", default=0.05) angular_tolerance: FloatProperty(name="Angular Tolerance", default=0.5) void_limit: IntProperty( name="Void Limit", diff --git a/src/bonsai/bonsai/bim/module/search/operator.py b/src/bonsai/bonsai/bim/module/search/operator.py index 2c7a506e78..a8c79c11bb 100644 --- a/src/bonsai/bonsai/bim/module/search/operator.py +++ b/src/bonsai/bonsai/bim/module/search/operator.py @@ -445,20 +445,17 @@ class FilterValueSuggestions(Operator): if pset.HasProperties: for prop in pset.HasProperties: if hasattr(prop, "Name") and prop.Name == property_name: - if hasattr(prop, "NominalValue") and prop.NominalValue: - try: - value = prop.NominalValue.wrappedValue - if value is not None and value != "": - if not hasattr(value, "is_a") and not isinstance( - value, (tuple, list) - ): - str_value = str(value) - if not str_value.startswith("#") and not str_value.startswith( - "(" - ): - property_values.add(str_value) - except: - continue + if prop.is_a("IfcPropertyEnumeratedValue"): + if hasattr(prop, "EnumerationReference") and prop.EnumerationReference: + enum_reference = prop.EnumerationReference + if hasattr(enum_reference, "EnumerationValues"): + for enum_value in enum_reference.EnumerationValues: + property_values.add(str(enum_value.wrappedValue)) + if hasattr(prop, "EnumerationValues") and prop.EnumerationValues: + for enum_value in prop.EnumerationValues: + property_values.add(str(enum_value.wrappedValue)) + elif hasattr(prop, "NominalValue") and prop.NominalValue: + property_values.add(str(prop.NominalValue.wrappedValue)) except: continue return property_values @@ -765,10 +762,7 @@ class Search(Operator): preferences = tool.Blender.get_addon_preferences() # Migrate old ! prefix filters to new filter_mode system when preferences are enabled - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: for filter_group in props.filter_groups: for ifc_filter in filter_group.filters: if ifc_filter.type not in ["entity", "instance"]: diff --git a/src/bonsai/bonsai/bim/module/system/__init__.py b/src/bonsai/bonsai/bim/module/system/__init__.py index 92782ae6fe..f44270cbe8 100644 --- a/src/bonsai/bonsai/bim/module/system/__init__.py +++ b/src/bonsai/bonsai/bim/module/system/__init__.py @@ -21,11 +21,13 @@ from . import ui, prop, operator, decorator classes = ( operator.AddPort, + operator.AddRelatedPortConnection, operator.AddSystem, operator.AddZone, operator.AssignSystem, operator.AssignUnassignFlowControl, operator.ConnectPort, + operator.CycleFlowDirection, operator.DisableEditingSystem, operator.DisableEditingZone, operator.DisableSystemEditingUI, diff --git a/src/bonsai/bonsai/bim/module/system/operator.py b/src/bonsai/bonsai/bim/module/system/operator.py index add214c09d..066e466bd0 100644 --- a/src/bonsai/bonsai/bim/module/system/operator.py +++ b/src/bonsai/bonsai/bim/module/system/operator.py @@ -198,7 +198,16 @@ class ShowPorts(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): # Ifc.Operator - as operator will sync object's position with IFC. - core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=tool.Ifc.get_entity(context.active_object)) + element = tool.Ifc.get_entity(context.active_object) + core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=element) + + for port in tool.System.get_ports(element): + connected_port = tool.System.get_connected_port(port) + if connected_port: + connected_port_obj = tool.Ifc.get_object(connected_port) + if not connected_port_obj: + parent_element = tool.System.get_port_relating_element(connected_port) + core.show_ports(tool.Ifc, tool.System, tool.Spatial, element=parent_element) class HidePorts(bpy.types.Operator, tool.Ifc.Operator): @@ -217,7 +226,7 @@ class HidePorts(bpy.types.Operator, tool.Ifc.Operator): class AddPort(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_port" - bl_description = "Add port at current cursor position" + bl_description = "Add USERDEFINED port at current cursor position" bl_label = "Add Port" bl_options = {"REGISTER", "UNDO"} @@ -246,7 +255,41 @@ class ConnectPort(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): obj1 = context.active_object obj2 = context.selected_objects[0] if context.selected_objects[1] == obj1 else context.selected_objects[1] - core.connect_port(tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2)) + direction = tool.Ifc.get_entity(obj1).FlowDirection or "NOTDEFINED" + core.connect_port( + tool.Ifc, port1=tool.Ifc.get_entity(obj1), port2=tool.Ifc.get_entity(obj2), direction=direction + ) + + +class AddRelatedPortConnection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_related_port_connection" + bl_label = "Connect Port" + bl_options = {"REGISTER", "UNDO"} + + relating_port_id: bpy.props.IntProperty() + + def invoke(self, context, event): + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + props = tool.System.get_system_props() + self.layout.prop(props, "related_port", text="Select Port") + + def _execute(self, context): + props = tool.System.get_system_props() + + if not props.related_port or props.related_port == "NONE": + return {"CANCELLED"} + + port_obj = bpy.data.objects.get(props.related_port) + related_port = tool.Ifc.get_entity(port_obj) + relating_port = tool.Ifc.get().by_id(self.relating_port_id) + + direction = relating_port.FlowDirection or "NOTDEFINED" + core.connect_port(tool.Ifc, port1=relating_port, port2=related_port, direction=direction) + PortData.is_loaded = False + + return {"FINISHED"} class DisconnectPort(bpy.types.Operator, tool.Ifc.Operator): @@ -256,6 +299,9 @@ class DisconnectPort(bpy.types.Operator, tool.Ifc.Operator): element_id: bpy.props.IntProperty(default=0, options={"SKIP_SAVE"}) + def invoke(self, context, event): + return context.window_manager.invoke_confirm(self, event) + def _execute(self, context): if self.element_id != 0: element = tool.Ifc.get().by_id(self.element_id) @@ -310,7 +356,8 @@ class MEPConnectElements(bpy.types.Operator, tool.Ifc.Operator): ports_distance[(port1, port2)] = distance closest_ports = min(ports_distance, key=lambda x: ports_distance[x]) - core.connect_port(tool.Ifc, *closest_ports) + direction = closest_ports[0].FlowDirection or "NOTDEFINED" + core.connect_port(tool.Ifc, *closest_ports, direction=direction) bpy.ops.bim.regenerate_distribution_element() return {"FINISHED"} @@ -379,6 +426,55 @@ class SetFlowDirection(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} +class CycleFlowDirection(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.cycle_flow_direction" + bl_label = "Cycle Flow Direction" + bl_options = {"REGISTER", "UNDO"} + port_id: bpy.props.IntProperty() + + @classmethod + def description(cls, context, operator): + port = tool.Ifc.get().by_id(operator.port_id) + if port and port.is_a("IfcDistributionPort"): + current_direction = port.FlowDirection or "NOTDEFINED" + return f"Current flow direction: {current_direction}. Click to cycle: SOURCE → SINK → SOURCEANDSINK → NOTDEFINED" + return "Cycle through flow directions: SOURCE → SINK → SOURCEANDSINK → NOTDEFINED → SOURCE..." + + def _execute(self, context): + port = tool.Ifc.get().by_id(self.port_id) + if not port or not port.is_a("IfcDistributionPort"): + return {"CANCELLED"} + + current_direction = port.FlowDirection or "NOTDEFINED" + + flow_cycle_map = { + "SOURCE": "SINK", + "SINK": "SOURCEANDSINK", + "SOURCEANDSINK": "NOTDEFINED", + "NOTDEFINED": "SOURCE", + } + next_direction = flow_cycle_map.get(current_direction, "SOURCE") + + tool.Ifc.run("attribute.edit_attributes", product=port, attributes={"FlowDirection": next_direction}) + + connected_port = tool.System.get_connected_port(port) + if connected_port: + connected_direction_map = { + "SOURCE": "SINK", + "SINK": "SOURCE", + "SOURCEANDSINK": "SOURCEANDSINK", + "NOTDEFINED": "NOTDEFINED", + } + connected_direction = connected_direction_map.get(next_direction, "NOTDEFINED") + tool.Ifc.run( + "attribute.edit_attributes", product=connected_port, attributes={"FlowDirection": connected_direction} + ) + + PortData.is_loaded = False + + return {"FINISHED"} + + class LoadZones(bpy.types.Operator): bl_idname = "bim.load_zones" bl_label = "Load Zones" diff --git a/src/bonsai/bonsai/bim/module/system/prop.py b/src/bonsai/bonsai/bim/module/system/prop.py index a603446c9f..8954ab67f2 100644 --- a/src/bonsai/bonsai/bim/module/system/prop.py +++ b/src/bonsai/bonsai/bim/module/system/prop.py @@ -92,6 +92,28 @@ def toggle_decorations(self: "BIMSystemProperties", context: bpy.types.Context) decorator.SystemDecorator.uninstall() +def get_available_ports_for_connection( + self: "BIMSystemProperties", context: bpy.types.Context +) -> list[tuple[str, str, str]]: + items = [] + active_object_ports = set(tool.System.get_ports(tool.Ifc.get_entity(context.active_object))) + + ifc_file = tool.Ifc.get() + for ifc_port in ifc_file.by_type("IfcDistributionPort"): + port = tool.Ifc.get_object(ifc_port) + if not port: + continue + + if tool.System.get_connected_port(ifc_port) is not None or ifc_port in active_object_ports: + continue + + port_object = tool.Ifc.get_object(tool.System.get_port_relating_element(ifc_port)) + suggestion = f"{port_object.name} > {port.name}" + items.append((port.name, suggestion, "")) + + return items if items else [("NONE", "Ports are hidden or not available", "")] + + class BIMSystemProperties(PropertyGroup): system_attributes: CollectionProperty(name="System Attributes", type=Attribute) is_editing: BoolProperty(name="Is Editing", default=False) @@ -107,6 +129,11 @@ class BIMSystemProperties(PropertyGroup): should_draw_decorations: BoolProperty( name="Should Draw Decorations", description="Toggle system decorations", update=toggle_decorations ) + related_port: EnumProperty( + name="Connect To Port", + description="Select a port to connect to", + items=get_available_ports_for_connection, + ) if TYPE_CHECKING: system_attributes: bpy.types.bpy_prop_collection_idprop[Attribute] @@ -119,6 +146,7 @@ class BIMSystemProperties(PropertyGroup): edited_system_id: int system_class: str should_draw_decorations: bool + related_port: str @property def active_system_ui_item(self) -> Union[System, None]: diff --git a/src/bonsai/bonsai/bim/module/system/ui.py b/src/bonsai/bonsai/bim/module/system/ui.py index 2d555f24c2..e41295f40e 100644 --- a/src/bonsai/bonsai/bim/module/system/ui.py +++ b/src/bonsai/bonsai/bim/module/system/ui.py @@ -30,10 +30,10 @@ if TYPE_CHECKING: FLOW_DIRECTION_TO_ICON = { - "SOURCE": "FORWARD", - "SINK": "BACK", - "SOURCEANDSINK": "ARROW_LEFTRIGHT", - "NOTDEFINED": "CHECKBOX_DEHLT", + "SOURCE": "FULLSCREEN_ENTER", + "SINK": "FULLSCREEN_EXIT", + "SOURCEANDSINK": "CHECKBOX_DEHLT", + "NOTDEFINED": "QUESTION", } @@ -163,44 +163,87 @@ class BIM_PT_ports(Panel): return row = self.layout.row(align=True) - row.label(text="Change Flow Direction:") - - current_flow_direction = PortData.data["selected_objects_flow_direction"] - for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): - row.operator( - "bim.set_flow_direction", - icon=FLOW_DIRECTION_TO_ICON[flow_direction], - depress=flow_direction == current_flow_direction, - text="", - ).direction = flow_direction - row.enabled = len(context.selected_objects) == 2 + row.label(text=f"Ports located in: {context.active_object.name} and connected Port/Objects:") row = self.layout.row(align=True) - row.label(text="Ports located on object and connected objects:") - row = self.layout.row(align=True) - cols = [row.column(align=True) for i in range(6)] + cols = [row.column(align=True) for i in range(9)] + cols[3].scale_x = 1.0 + cols[6].scale_x = 1.0 + cols[8].scale_x = 1.33 for port_data in PortData.data["located_ports_data"]: flow_direction_icon = FLOW_DIRECTION_TO_ICON[port_data["FlowDirection"] or "NOTDEFINED"] - if port_data["port_obj_name"]: - cols[0].label(text="", icon=flow_direction_icon) - cols[1].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port_data["id"] - cols[2].label(text=port_data["port_obj_name"]) + + if port_data["connected_obj_name"]: + cols[0].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port_data["id"] + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = port_data["id"] else: - cols[0].label(text="", icon=flow_direction_icon) - cols[1].label(text="", icon="HIDE_ON") - cols[2].label(text="Port is hidden") + op = cols[0].operator("bim.add_related_port_connection", text="", icon="PLUGIN") + op.relating_port_id = port_data["id"] + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = port_data["id"] + + if port_data["port_obj_name"]: + cols[2].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = port_data["id"] + cols[3].label(text=port_data["port_obj_name"]) + else: + cols[2].label(text="", icon="HIDE_ON") + cols[3].label(text="Port is hidden") if port_data["connected_obj_name"]: connected_obj = bpy.data.objects[port_data["connected_obj_name"]] - cols[3].operator("bim.disconnect_port", text="", icon="UNLINKED").element_id = port_data["id"] + + port = tool.Ifc.get().by_id(port_data["id"]) + connected_port = tool.System.get_connected_port(port) + if connected_port: + connected_port_obj = tool.Ifc.get_object(connected_port) + if connected_port_obj: + connected_port_flow_dir = FLOW_DIRECTION_TO_ICON[connected_port.FlowDirection or "NOTDEFINED"] + op = cols[4].operator( + "bim.cycle_flow_direction", text="", icon=connected_port_flow_dir, emboss=True + ) + op.port_id = connected_port.id() + cols[5].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ( + connected_port.id() + ) + cols[6].label(text=connected_port_obj.name) + else: + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") + cols[6].label(text="Port is hidden") + else: + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") + cols[6].label(text="") + ifc_id = tool.Blender.get_ifc_definition_id(connected_obj) - cols[4].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id - cols[5].label(text=port_data["connected_obj_name"]) + cols[7].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols[8].label(text=port_data["connected_obj_name"]) else: - cols[3].label(text="", icon="UNLINKED") - cols[4].label(text="", icon="BLANK1") - cols[5].label(text="Port is disconnected") + blank4 = cols[4].column(align=True) + blank4.scale_x = 0.1 + blank4.label(text="", icon="BLANK1") + blank5 = cols[5].column(align=True) + blank5.scale_x = 0.1 + blank5.label(text="", icon="BLANK1") + + cols[6].label(text="Port is disconnected") + + blank7 = cols[7].column(align=True) + blank7.scale_x = 0.1 + blank7.label(text="", icon="BLANK1") + blank8 = cols[8].column(align=True) + blank8.scale_x = 0.1 + blank8.label(text="", icon="BLANK1") class BIM_PT_port(Panel): @@ -223,14 +266,8 @@ class BIM_PT_port(Panel): return True def draw(self, context): - self.props = tool.System.get_system_props() - layout = self.layout row = layout.row(align=True) - row.label(text="IfcDistributionPort") - row.operator("bim.connect_port", icon="PLUGIN", text="") - row.operator("bim.disconnect_port", icon="UNLINKED", text="") - row.operator("bim.remove_port", icon="X", text="") if not PortData.is_loaded: PortData.load() @@ -238,42 +275,56 @@ class BIM_PT_port(Panel): if not PortData.data["is_port"]: return - element = tool.Ifc.get_entity(context.active_object) - current_flow_direction = str(element.FlowDirection) - row = layout.row(align=True) - row.label(text="Flow Direction:") - row.label(text=current_flow_direction) - - # port located on - row = layout.row(align=True) relating_object_name = PortData.data["port_relating_object_name"] - relating_object = bpy.data.objects[relating_object_name] - row.label(text="Port located on:") - row.label(text=relating_object_name) - ifc_id = tool.Blender.get_ifc_definition_id(relating_object) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + row.label(text=f"IfcDistributionPort located in: {relating_object_name}") + row.operator("bim.remove_port", icon="X", text="") + + element = tool.Ifc.get_entity(context.active_object) - # object connected to the port row = layout.row(align=True) - connected_object_name = PortData.data["port_connected_object_name"] - if connected_object_name: - connected_object = bpy.data.objects[connected_object_name] - row.label(text="Port connected to:") - row.label(text=connected_object_name) - ifc_id = tool.Blender.get_ifc_definition_id(connected_object) - row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols = [row.column(align=True) for i in range(9)] + cols[3].scale_x = 1.0 + cols[6].scale_x = 1.0 + cols[8].scale_x = 1.33 + + flow_direction_icon = FLOW_DIRECTION_TO_ICON[element.FlowDirection or "NOTDEFINED"] + connected_port = tool.System.get_connected_port(element) + + if connected_port: + cols[0].operator("bim.disconnect_port", text="", icon="UNLINKED") + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = element.id() else: - row.label(text="Port is not connected to any element") + cols[0].operator("bim.connect_port", icon="PLUGIN", text="") + op = cols[1].operator("bim.cycle_flow_direction", text="", icon=flow_direction_icon, emboss=True) + op.port_id = element.id() - row = layout.row(align=True) - row.label(text="Change Flow Direction:") - for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): - row.operator( - "bim.set_flow_direction", - icon=FLOW_DIRECTION_TO_ICON[flow_direction], - depress=flow_direction == current_flow_direction, - text="", - ).direction = flow_direction + cols[2].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = element.id() + cols[3].label(text=context.active_object.name) + + if connected_port: + connected_port_flow_dir = FLOW_DIRECTION_TO_ICON[connected_port.FlowDirection or "NOTDEFINED"] + op = cols[4].operator("bim.cycle_flow_direction", text="", icon=connected_port_flow_dir, emboss=True) + op.port_id = connected_port.id() + cols[5].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = connected_port.id() + connected_port_obj = tool.Ifc.get_object(connected_port) + cols[6].label(text=connected_port_obj.name if connected_port_obj else "Hidden Port") + + connected_object_name = PortData.data["port_connected_object_name"] + if connected_object_name: + connected_obj = bpy.data.objects[connected_object_name] + ifc_id = tool.Blender.get_ifc_definition_id(connected_obj) + cols[7].operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF").ifc_id = ifc_id + cols[8].label(text=connected_object_name) + else: + cols[7].label(text="", icon="BLANK1") + cols[8].label(text="") + else: + cols[4].label(text="", icon="BLANK1") + cols[5].label(text="", icon="BLANK1") + cols[6].label(text="Port is disconnected") + cols[7].label(text="", icon="BLANK1") + cols[8].label(text="") class BIM_PT_flow_controls(Panel): diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index c550c00872..49c2cf51b6 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -724,15 +724,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) chain_filter_with_set_operations: BoolProperty( - name="NEW filter mode: Enable chained filters with set operations", + name="NEW Filter mode: Enable chained filters with set operations", description="Enable chaining search filters with set operations: ADD (union: combine sets), SUBTRACT (difference: remove from set), FILTER (intersection: only elements in both sets), with autocomplete suggestions for filter values", default=False, ) - default_filter_with_set_operations_for_globalid_and_class: BoolProperty( - name="DEFAULT filter mode: Enable set operations for GlobalId/Class", - description="Enable ADD/SUBTRACT/FILTER toggle buttons on entity (Class) and instance (GlobalId) filters for the DEFAULT filter mode", - default=False, - ) save_metadata_blend_file: BoolProperty( name="Save non ifc data to metadata blend File", @@ -783,7 +778,6 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): container_hide_show_isolate: bool mass_time_units_in_wizard: bool chain_filter_with_set_operations: bool - default_filter_with_set_operations_for_globalid_and_class: bool save_metadata_blend_file: bool def draw(self, context: bpy.types.Context) -> None: @@ -979,14 +973,9 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): def draw_extras_settings(self, layout: bpy.types.UILayout, context: bpy.types.Context) -> None: layout.prop(self, "container_hide_show_isolate") layout.prop(self, "mass_time_units_in_wizard") - layout.label(text="Filtering modes:") - box = layout.box() - row = box.row(align=True) + row = layout.row(align=True) row.prop(self, "chain_filter_with_set_operations") row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/3270" - row = box.row(align=True) - row.prop(self, "default_filter_with_set_operations_for_globalid_and_class") - row.operator("bim.open_uri", text="", icon="HELP").uri = "https://community.osarch.org/discussion/comment/27030" layout.prop(self, "save_metadata_blend_file") if self.save_metadata_blend_file: row = layout.row() diff --git a/src/bonsai/bonsai/core/system.py b/src/bonsai/bonsai/core/system.py index fcdb95e72d..01f1b7166e 100644 --- a/src/bonsai/bonsai/core/system.py +++ b/src/bonsai/bonsai/core/system.py @@ -123,9 +123,8 @@ def hide_ports(ifc: type[tool.Ifc], system: type[tool.System], element: ifcopens def add_port(ifc: type[tool.Ifc], system: type[tool.System], element: ifcopenshell.entity_instance) -> None: system.load_ports(element, system.get_ports(element)) - obj = system.create_empty_at_cursor_with_element_orientation(element) - port = system.run_root_assign_class(obj=obj, ifc_class="IfcDistributionPort", should_add_representation=False) - ifc.run("system.assign_port", element=element, port=port) + port = system.create_port_at_cursor(element) + system.load_ports(element, [port]) def remove_port(ifc: type[tool.Ifc], system: type[tool.System], port: ifcopenshell.entity_instance) -> None: @@ -133,8 +132,13 @@ def remove_port(ifc: type[tool.Ifc], system: type[tool.System], port: ifcopenshe ifc.run("root.remove_product", product=port) -def connect_port(ifc: type[tool.Ifc], port1: ifcopenshell.entity_instance, port2: ifcopenshell.entity_instance) -> None: - ifc.run("system.connect_port", port1=port1, port2=port2) +def connect_port( + ifc: type[tool.Ifc], + port1: ifcopenshell.entity_instance, + port2: ifcopenshell.entity_instance, + direction: str = "NOTDEFINED", +) -> None: + ifc.run("system.connect_port", port1=port1, port2=port2, direction=direction) def disconnect_port(ifc: type[tool.Ifc], port: ifcopenshell.entity_instance) -> None: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 36afd7d66c..e231571834 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -1082,6 +1082,7 @@ class Surveyor: @interface class System: def create_empty_at_cursor_with_element_orientation(cls, element): pass + def create_port_at_cursor(cls, system): pass def delete_element_objects(cls, elements): pass def disable_editing_system(cls): pass def disable_system_editing_ui(cls): pass diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 7c46135b26..c357287a6b 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -50,12 +50,12 @@ class Collector(bonsai.core.tool.Collector): tool.Geometry.lock_object(obj) element = (element.PartOfU or element.PartOfV or element.PartOfW)[0] if not tool.Spatial.get_grid_props().is_visible: - obj.hide_set(True) + obj.hide_viewport = True elif element.is_a("IfcGrid"): if tool.Geometry.is_locked(element): tool.Geometry.lock_object(obj) if not tool.Spatial.get_grid_props().is_visible: - obj.hide_set(True) + obj.hide_viewport = True if element.is_a("IfcProject"): if tool.Geometry.is_locked(element): @@ -71,6 +71,8 @@ class Collector(bonsai.core.tool.Collector): tool.Geometry.lock_object(obj) collection = cls._create_project_child_collection("IfcSpace") cls.link_collection_object_safe(collection, obj) + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcStructuralItem"): collection = cls._create_project_child_collection("IfcStructuralItem") cls.link_collection_object_safe(collection, obj) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2f4c4b77b7..28506bd8db 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -66,8 +66,8 @@ if TYPE_CHECKING: BIMRailingProperties, BIMExternalParametricGeometryProperties, BIMPolylineProperties, - BIMProductPreviewProperties, ) + from sverchok.core.node_group import SvGroupTreeNode class Model(bonsai.core.tool.Model): @@ -108,11 +108,6 @@ class Model(bonsai.core.tool.Model): assert (scene := bpy.context.scene) return scene.BIMPolylineProperties # pyright: ignore[reportAttributeAccessIssue] - @classmethod - def get_product_preview_props(cls) -> BIMProductPreviewProperties: - assert (scene := bpy.context.scene) - return scene.BIMProductPreviewProperties # pyright: ignore[reportAttributeAccessIssue] - @classmethod def convert_si_to_unit(cls, value: T) -> T: if isinstance(value, (tuple, list)): @@ -2571,13 +2566,21 @@ class Model(bonsai.core.tool.Model): return [s for s in group_node.inputs if s.type != "GEOMETRY"] + @classmethod + def get_ifcsverchok_group_node(cls, node_tree: sverchok.node_tree.SverchCustomTree) -> SvGroupTreeNode: + from sverchok.core.node_group import SvGroupTreeNode + + return next(n for n in node_tree.nodes if isinstance(n, SvGroupTreeNode) and n.label == "BBIM_EPG") + @classmethod def get_ifcsverchok_shape_output( cls, node_tree: sverchok.node_tree.SverchCustomTree ) -> ifcsverchok.nodes.ifc.shape_builder.shape_output.SvSbShapeOutput: from ifcsverchok.nodes.ifc.shape_builder.shape_output import SvSbShapeOutput - return next(n for n in node_tree.nodes if isinstance(n, SvSbShapeOutput)) + group_node = cls.get_ifcsverchok_group_node(node_tree) + subtree = group_node.node_tree + return next(n for n in subtree.nodes if isinstance(n, SvSbShapeOutput)) @classmethod def update_mesh_from_sverchok( @@ -2750,3 +2753,20 @@ class Model(bonsai.core.tool.Model): list(nodes_to_update) finally: SvIfcStore.use_bonsai_file = False + + @classmethod + def create_bmesh_from_vertices(cls, vertices, is_closed=False): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in vertices] + if is_closed: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + new_edges.append( + bm.edges.new((new_verts[-1], new_verts[0])) + ) # Add an edge between the last an first point to make it closed. + else: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + return bm diff --git a/src/bonsai/bonsai/tool/search.py b/src/bonsai/bonsai/tool/search.py index a0e2e17456..439e3469b3 100644 --- a/src/bonsai/bonsai/tool/search.py +++ b/src/bonsai/bonsai/tool/search.py @@ -132,10 +132,7 @@ class Search(bonsai.core.tool.Search): else: value = ifc_filter.value - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: value = value.lstrip("!") if ifc_filter.filter_mode == "SUBTRACT": value = f"!{value}" @@ -144,10 +141,7 @@ class Search(bonsai.core.tool.Search): elif ifc_filter.type == "entity": value = ifc_filter.value - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): + if preferences.chain_filter_with_set_operations: value = value.lstrip("!") if ifc_filter.filter_mode == "SUBTRACT": value = f"!{value}" @@ -215,19 +209,10 @@ class Search(bonsai.core.tool.Search): if filter_index == 0: mode = "ADD" else: - if ifc_filter.type in ["entity", "instance"]: - if ( - preferences.chain_filter_with_set_operations - or preferences.default_filter_with_set_operations_for_globalid_and_class - ): - mode = ifc_filter.filter_mode - else: - mode = "FILTER" if group_results else "ADD" + if preferences.chain_filter_with_set_operations: + mode = ifc_filter.filter_mode else: - if preferences.chain_filter_with_set_operations: - mode = ifc_filter.filter_mode - else: - mode = "FILTER" if group_results else "ADD" + mode = "FILTER" if group_results else "ADD" if mode == "ADD": results = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) diff --git a/src/bonsai/bonsai/tool/spatial.py b/src/bonsai/bonsai/tool/spatial.py index 6ca29343e9..a177a237e8 100644 --- a/src/bonsai/bonsai/tool/spatial.py +++ b/src/bonsai/bonsai/tool/spatial.py @@ -1213,18 +1213,18 @@ class Spatial(bonsai.core.tool.Spatial): for element in elements: if obj := tool.Ifc.get_object(element): if obj.hide_viewport is True and is_visible: - obj.hide_set(False) + obj.hide_viewport = False elif obj.hide_viewport is False and not is_visible: - obj.hide_set(True) + obj.hide_viewport = True @classmethod def set_grid_visibility(cls, is_visible: bool) -> None: for element in tool.Ifc.get().by_type("IfcGrid") + tool.Ifc.get().by_type("IfcGridAxis"): if obj := tool.Ifc.get_object(element): if obj.hide_viewport is True and is_visible: - obj.hide_set(False) + obj.hide_viewport = False elif obj.hide_viewport is False and not is_visible: - obj.hide_set(True) + obj.hide_viewport = True @classmethod def toggle_spaces_visibility_wired_and_textured(cls, spaces: list[ifcopenshell.entity_instance]) -> None: diff --git a/src/bonsai/bonsai/tool/system.py b/src/bonsai/bonsai/tool/system.py index efaaa802c5..3566dbd863 100644 --- a/src/bonsai/bonsai/tool/system.py +++ b/src/bonsai/bonsai/tool/system.py @@ -100,6 +100,7 @@ class System(bonsai.core.tool.System): @classmethod def create_empty_at_cursor_with_element_orientation(cls, element: ifcopenshell.entity_instance) -> bpy.types.Object: + # Is this necessary anymore? element_obj = tool.Ifc.get_object(element) obj = bpy.data.objects.new("Port", None) obj.matrix_world = element_obj.matrix_world @@ -107,6 +108,26 @@ class System(bonsai.core.tool.System): bpy.context.scene.collection.objects.link(obj) return obj + @classmethod + def create_port_at_cursor(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance: + ifc_file = tool.Ifc.get() + element_obj = tool.Ifc.get_object(element) + + port = ifcopenshell.api.system.add_port(ifc_file, element=element) + port.FlowDirection = "NOTDEFINED" + port.PredefinedType = "USERDEFINED" + + systems = ifcopenshell.util.system.get_element_systems(element) + system = systems[0] if systems else None + port.SystemType = getattr(system, "PredefinedType", None) or "USERDEFINED" + + matrix = element_obj.matrix_world.copy() + matrix.translation = bpy.context.scene.cursor.matrix.translation + + ifcopenshell.api.geometry.edit_object_placement(ifc_file, product=port, matrix=matrix, is_si=True) + + return port + @classmethod def delete_element_objects(cls, elements: list[ifcopenshell.entity_instance]) -> None: for element in elements: @@ -192,11 +213,22 @@ class System(bonsai.core.tool.System): ifc_importer.process_context_filter() ifc_importer.create_generic_elements(set(ports_to_create)) - container = ifcopenshell.util.element.get_container(element) - if container: - collection = tool.Blender.get_object_bim_props(tool.Ifc.get_object(container)).collection - ifc_importer.collections[container.GlobalId] = collection - ifc_importer.place_objects_in_collections() + if element.is_a("IfcTypeProduct"): + target_collection = None + for collection in obj.users_collection: + target_collection = collection + break + + if target_collection: + for port_obj in ifc_importer.added_data.values(): + if isinstance(port_obj, bpy.types.Object): + tool.Collector.link_collection_object_safe(target_collection, port_obj) + else: + container = ifcopenshell.util.element.get_container(element) + if container: + collection = tool.Blender.get_object_bim_props(tool.Ifc.get_object(container)).collection + ifc_importer.collections[container.GlobalId] = collection + ifc_importer.place_objects_in_collections() for port_obj in ifc_importer.added_data.values(): assert isinstance(port_obj, bpy.types.Object) diff --git a/src/bonsai/test/bim/test_feature.py b/src/bonsai/test/bim/test_feature.py index 64d7e7cb27..036e0bf464 100644 --- a/src/bonsai/test/bim/test_feature.py +++ b/src/bonsai/test/bim/test_feature.py @@ -337,7 +337,7 @@ def an_untestable_scenario(): def an_empty_blender_session(): IfcStore.purge() if not PYTEST_BLENDER_NO_BACKGROUND: - bpy.ops.wm.read_homefile(app_template="") + bpy.ops.wm.read_homefile(app_template="", use_factory_startup=True) if len(bpy.data.objects) > 0: bpy.data.batch_remove(bpy.data.objects) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) diff --git a/src/bonsai/test/core/test_system.py b/src/bonsai/test/core/test_system.py index d907059b98..4612e14d69 100644 --- a/src/bonsai/test/core/test_system.py +++ b/src/bonsai/test/core/test_system.py @@ -153,11 +153,10 @@ class TestAddPort: def test_run(self, ifc, system): system.get_ports("element").should_be_called().will_return(["port"]) system.load_ports("element", ["port"]).should_be_called() - system.create_empty_at_cursor_with_element_orientation("element").should_be_called().will_return("obj") - system.run_root_assign_class( - obj="obj", ifc_class="IfcDistributionPort", should_add_representation=False - ).should_be_called().will_return("port") - ifc.run("system.assign_port", element="element", port="port").should_be_called() + #system.create_empty_at_cursor_with_element_orientation("element").should_be_called().will_return("obj") + #system.run_root_assign_class(obj="obj", ifc_class="IfcDistributionPort", should_add_representation=False).should_be_called().will_return("port") + system.create_port_at_cursor("element").should_be_called().will_return("port") + system.load_ports("element", ["port"]).should_be_called() subject.add_port(ifc, system, element="element") diff --git a/src/bonsai/test/tool/test_system.py b/src/bonsai/test/tool/test_system.py index de8e9026b0..ba20305140 100644 --- a/src/bonsai/test/tool/test_system.py +++ b/src/bonsai/test/tool/test_system.py @@ -103,6 +103,19 @@ class TestCreateEmptyAtCursorWithElementOrientation(NewFile): obj = subject.create_empty_at_cursor_with_element_orientation(element) assert obj.matrix_world == bpy.context.scene.cursor.matrix +class TestCreatePortAtCursor(NewFile): + def test_run(self): + assert bpy.context.scene + ifc = ifcopenshell.file() + tool.Ifc().set(ifc) + system = ifcopenshell.api.system.add_system(ifc) + element = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcDuctSegment") + ifcopenshell.api.system.assign_system(ifc, products=[element], system=system) + obj = tool.Ifc.link(element, bpy.data.objects.new("Object", None)) + port = subject.create_port_at_cursor(element) + assert port.is_a("IfcDistributionPort") + assert ifcopenshell.util.system.get_ports(element) == [port] + class TestDeleteElementObjects(NewFile): def test_run(self): diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index 87154855ae..ff633095bf 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -184,6 +184,78 @@ class ClassPropertyContractV1(TypedDict): qudtCodes: NotRequired[list[str]] +class ClassRelationItemContractV1(TypedDict): + relationType: str + classUri: str + className: NotRequired[str] + fraction: NotRequired[float] + dictionaryUri: NotRequired[str] + + +class ClassRelationsContractV1(TypedDict): + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classUri: NotRequired[str] + areReversedRelations: NotRequired[bool] + classRelations: NotRequired[list[ClassRelationItemContractV1]] + + +class ClassPropertiesContractV1(TypedDict): + classUri: NotRequired[str] + totalCount: NotRequired[int] + offset: NotRequired[int] + count: NotRequired[int] + classProperties: list[ClassPropertyContractV1] + + +class ClassPropertyItemContractV1(TypedDict): + name: str + propertySet: str + uri: str + description: NotRequired[str] + definition: NotRequired[str] + dataType: NotRequired[str] + dimension: NotRequired[str] + dimensionLength: NotRequired[int] + dimensionMass: NotRequired[int] + dimensionTime: NotRequired[int] + dimensionElectricCurrent: NotRequired[int] + dimensionThermodynamicTemperature: NotRequired[int] + dimensionAmountOfSubstance: NotRequired[int] + dimensionLuminousIntensity: NotRequired[int] + dynamicParameterPropertyCodes: NotRequired[list[str]] + example: NotRequired[str] + isDynamic: NotRequired[bool] + isRequired: NotRequired[bool] + isWritable: NotRequired[bool] + maxExclusive: NotRequired[float] + maxInclusive: NotRequired[float] + minExclusive: NotRequired[float] + minInclusive: NotRequired[float] + pattern: NotRequired[str] + physicalQuantity: NotRequired[str] + allowedValues: NotRequired[list[ClassPropertyValueItemContractV1]] + predefinedValue: NotRequired[str] + propertyCode: NotRequired[str] + propertyDictionaryName: NotRequired[str] + propertyDictionaryUri: NotRequired[str] + propertyUri: NotRequired[str] + propertyStatus: NotRequired[str] + propertyValueKind: NotRequired[str] + symbol: NotRequired[str] + units: NotRequired[list[str]] + qudtCodes: NotRequired[list[str]] + + +class ClassPropertyValueItemContractV1(TypedDict): + uri: NotRequired[str] + code: NotRequired[str] + value: str + description: NotRequired[str] + sortNumber: NotRequired[int] + + class PropertyContractV5(TypedDict): dictionaryUri: NotRequired[str] activationDateUtc: str @@ -710,6 +782,56 @@ class Client: params = {k: v for k, v in params.items() if v is not None} return self.get(endpoint, params) + def get_class_relations( + self, + class_uri: str, + get_reverse_relations: bool = False, + search_text: str = "", + offset: int = 0, + limit: int = 1000, + language_code: str = "", + version=1, + ) -> ClassRelationsContractV1: + """ + Get class relations or reverse relations (paginated) + """ + endpoint = f"Class/Relations/v{version}" + params = { + "ClassUri": class_uri, + "GetReverseRelations": get_reverse_relations, + "SearchText": search_text, + "Offset": offset, + "Limit": limit, + "languageCode": language_code, + } + return self.get(endpoint, params) + + def get_class_properties( + self, + class_uri: str, + property_set: str = "", + property_code: str = "", + search_text: str = "", + offset: int = 0, + limit: int = 1000, + language_code: str = "", + version=1, + ) -> ClassPropertiesContractV1: + """ + Get class properties (paginated) + """ + endpoint = f"Class/Properties/v{version}" + params = { + "ClassUri": class_uri, + "PropertySet": property_set, + "PropertyCode": property_code, + "SearchText": search_text, + "Offset": offset, + "Limit": limit, + "languageCode": language_code, + } + return self.get(endpoint, params) + def get_property(self, uri, language_code="", version: int = 5) -> PropertyContractV5: """ Get Property details. diff --git a/src/bsdd/tests/test_bsdd.py b/src/bsdd/tests/test_bsdd.py index aea17b9eb9..9dab01025c 100644 --- a/src/bsdd/tests/test_bsdd.py +++ b/src/bsdd/tests/test_bsdd.py @@ -37,6 +37,22 @@ def test_get_class(): ] +def test_get_class_relations(): + uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"] + ifc4x3_light_fixture_relations = client.get_class_properties(uri_light_fixture, True) + assert "Electrical unit for light-line system" and "Tubelight system" in [ + r["className"] for r in ifc4x3_light_fixture_relations["classRelations"] + ] + + +def test_get_class_properties(): + uri_light_fixture = next(l for l in get_ifc_classes()["classes"] if "IfcLightFixture" == l["code"])["uri"] + ifc4x3_light_fixture_properties = client.get_class_properties(uri_light_fixture) + assert "Maintenance Factor" and "Light Fixture Mounting Type" in [ + l["name"] for l in ifc4x3_light_fixture_properties["classProperties"] + ] + + def test_search_class(): ss_heat_pump_sys = client.search_class("Ss_60_40_36", [nbs_uri]) li = [l + "source heat pump systems" for l in ["Air ", "Ground ", "Water "]] diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst index 65c21cafef..817630ad7d 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/geometry_creation.rst @@ -340,8 +340,12 @@ In IFC, meshes may be stored as **Faceted BReps**, **Tessellations**, or # These vertices and faces represent a 2m square 1m high pyramid in SI units. # Note how they are nested lists. Each nested list represents a "mesh". There may be multiple meshes. - vertices = [[(0.,0.,0.), (0.,2.,0.), (2.,2.,0.), (2.,0.,0.), (1.,1.,1.)]] - faces = [[(0,1,2,3), (0,4,1), (1,4,2), (2,4,3), (3,4,0)]] + vertices = [ + [(0.,0.,0.), (0.,2.,0.), (2.,2.,0.), (2.,0.,0.), (1.,1.,1.)] # A single mesh + ] + faces = [ + [(0,1,2,3), (0,4,1), (1,4,2), (2,4,3), (3,4,0)] # A single mesh + ] representation = ifcopenshell.api.geometry.add_mesh_representation(model, context=body, vertices=vertices, faces=faces) .. image:: images/mesh-representation.png diff --git a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py index 893b32cc02..0a5aee0e1c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/project/append_asset.py @@ -381,6 +381,7 @@ class Usecase: def append_type_product(self): self.whitelisted_inverse_attributes = { "IfcObjectDefinition": ["HasAssociations"], + "IfcDistributionElementType": ["IsNestedBy"], self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"], "IfcRepresentationItem": ["StyledByItem", "LayerAssignment"], "IfcRepresentation": ["LayerAssignments"], @@ -397,6 +398,7 @@ class Usecase: "IfcObjectDefinition": ["HasAssociations"], "IfcObject": ["IsDefinedBy.IfcRelDefinesByProperties"], "IfcElement": ["HasOpenings"], + "IfcDistributionElement": ["IsNestedBy"], self.base_material_class: ["HasExternalReferences", "HasProperties", "HasRepresentation"], "IfcRepresentationItem": [ "StyledByItem", @@ -569,6 +571,8 @@ class Usecase: return False elif element.is_a("IfcRoot") and self.by_guid(element.GlobalId) is not None: return False + elif element.is_a("IfcDistributionPort"): + return False elif element.is_a(self.target_class): return True elif self.target_class == "IfcProduct" and element.is_a("IfcTypeProduct"): diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index 7ae790e753..b6b72a192b 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -65,8 +65,6 @@ def disconnect_port(file: ifcopenshell.file, port: ifcopenshell.entity_instance) rels += port.ConnectedFrom or () for rel in rels: - rel.RelatingPort.FlowDirection = None - rel.RelatedPort.FlowDirection = None history = rel.OwnerHistory file.remove(rel) if history: diff --git a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py index 3b984dd4d7..44fd47cebc 100644 --- a/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py +++ b/src/ifcsverchok/nodes/ifc/shape_builder/extrude.py @@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Literal import bpy import ifcopenshell from ifcopenshell.util.shape_builder import ShapeBuilder +from sverchok.core.sockets import SvVerticesSocket from sverchok.data_structure import updateNode from sverchok.node_tree import SverchCustomTreeNode @@ -62,6 +63,7 @@ class SvIfcSbExtrude(bpy.types.Node, SverchCustomTreeNode, ifcsverchok.helper.Sv self.inputs, "Position", data_type="list[list[tuple[float, float, float]]]", + socket_type=SvVerticesSocket, ) helper.create_socket( self.outputs, diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index ea21aa15fd..d7403e2594 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -300,23 +300,15 @@ class Specification: if not is_applicable: continue self.applicable_entities.append(element) - for facet in self.requirements: - result = facet(element) - is_pass = bool(result) - if self.maxOccurs != 0: # This is a required or optional specification - if is_pass: + if self.maxOccurs != 0: # Requirements are skipped for prohibited applicability + for facet in self.requirements: + result = facet(element) + if bool(result): self.passed_entities.add(element) facet.passed_entities.add(element) else: self.failed_entities.add(element) facet.failures.append(FacetFailure(element=element, reason=str(result))) - else: # This is a prohibited specification - if is_pass: - self.failed_entities.add(element) - facet.failures.append(FacetFailure(element=element, reason=str(result))) - else: - self.passed_entities.add(element) - facet.passed_entities.add(element) self.status = True for facet in self.requirements: diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index 220d0f96a9..38b3ba8f5b 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -81,10 +81,12 @@ class ResultsSpecification(TypedDict): description: str instructions: str status: bool + is_skipped: bool is_ifc_version: bool total_applicable: int total_applicable_pass: int total_applicable_fail: int + applicable_entities: list[ResultsEntity] percent_applicable_pass: ResultsPercent total_checks: int total_checks_pass: int @@ -367,16 +369,24 @@ class Json(Reporter): cardinality = "optional" elif specification.minOccurs == 0 and specification.maxOccurs == 0: cardinality = "prohibited" + elif specification.minOccurs >= 1: + # Any minimum occurrence >= 1 means the specification is required + cardinality = "required" + else: + # minOccurs == 0 with any other maxOccurs value means optional + cardinality = "optional" return ResultsSpecification( name=specification.name, description=specification.description, instructions=specification.instructions, status=specification.status, + is_skipped=cardinality == "optional" and total_checks == 0, is_ifc_version=specification.is_ifc_version, total_applicable=total_applicable, total_applicable_pass=total_applicable_pass, total_applicable_fail=total_applicable - total_applicable_pass, + applicable_entities=self.report_applicable_entities(specification), percent_applicable_pass=percent_applicable_pass, total_checks=total_checks, total_checks_pass=total_checks_pass, @@ -387,6 +397,24 @@ class Json(Reporter): requirements=requirements, ) + def report_applicable_entities(self, specification: Specification) -> list[ResultsEntity]: + return [ + ResultsEntity( + { + "element": e, + "element_type": ifcopenshell.util.element.get_type(e), + "class": e.is_a(), + "predefined_type": ifcopenshell.util.element.get_predefined_type(e), + "name": getattr(e, "Name", None), + "description": getattr(e, "Description", None), + "id": e.id(), + "global_id": getattr(e, "GlobalId", None), + "tag": getattr(e, "Tag", None), + } + ) + for e in specification.applicable_entities + ] + def report_passed_entities(self, requirement: Facet) -> list[ResultsEntity]: return [ ResultsEntity( @@ -448,11 +476,13 @@ class Html(Json): def report(self) -> None: super().report() for spec in self.results["specifications"]: - if spec["cardinality"] == "optional" and spec["total_checks"] == 0: - spec["is_skipped"] = True spec["is_prohibited"] = spec["cardinality"] == "prohibited" spec["cardinality"] = spec["cardinality"].capitalize() spec["has_requirements"] = bool(spec["requirements"]) + total_applicable_entities = len(spec["applicable_entities"]) + spec["applicable_entities"] = self.limit_entities(spec["applicable_entities"]) + spec["has_omitted_applicable"] = total_applicable_entities > self.entity_limit + spec["total_omitted_applicable"] = total_applicable_entities - self.entity_limit for requirement in spec["requirements"]: total_passed_entities = len(requirement["passed_entities"]) total_failed_entities = len(requirement["failed_entities"]) diff --git a/src/ifctester/ifctester/templates/report.html b/src/ifctester/ifctester/templates/report.html index 456e90e5f4..51beb5c0f5 100644 --- a/src/ifctester/ifctester/templates/report.html +++ b/src/ifctester/ifctester/templates/report.html @@ -152,7 +152,6 @@

Requirements

- {{/has_requirements}}
    {{#requirements}}
  1. @@ -252,6 +251,45 @@
  2. {{/requirements}}
+ {{/has_requirements}} + {{#is_prohibited}} + {{#total_applicable}} + + + + + + + + + + + + + {{#applicable_entities}} + + + + + + + + + {{#extra_of_type}} + + + + {{/extra_of_type}} + {{/applicable_entities}} + {{#has_omitted_applicable}} + + + + {{/has_omitted_applicable}} + +
ClassPredefinedTypeNameDescriptionGlobalIdTag
{{class}}{{predefined_type}}{{name}}{{description}}{{global_id}}{{tag}}
... {{extra_of_type}} more of the same element type ({{type_name}} with Tag {{type_tag}} and GlobalId {{type_global_id}}) not shown ...
... {{total_omitted_applicable}} more failing elements not shown out of {{total_applicable}} total ...
+ {{/total_applicable}} + {{/is_prohibited}} {{/specifications}} diff --git a/src/ifctester/webapp/package-lock.json b/src/ifctester/webapp/package-lock.json index 392c2dfe13..35d03c74fa 100644 --- a/src/ifctester/webapp/package-lock.json +++ b/src/ifctester/webapp/package-lock.json @@ -1955,9 +1955,9 @@ } }, "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, "license": "MIT", "dependencies": { @@ -1967,22 +1967,6 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "dev": true, - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mode-watcher": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz", @@ -2867,17 +2851,16 @@ } }, "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.6.tgz", + "integrity": "sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", + "minizlib": "^3.1.0", "yallist": "^5.0.0" }, "engines": { diff --git a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte index e9645e0984..2fda5f0c2c 100644 --- a/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte +++ b/src/ifctester/webapp/src/pages/Home/IdsViewer.svelte @@ -50,13 +50,7 @@ function getSpecificationStatus(specIndex, auditData) { const spec = auditData.specifications[specIndex]; if (!spec) return null; - - // If no applicable elements and no checks, and it passed, it's actually skipped - if (spec.total_applicable === 0 && spec.total_checks === 0 && spec.status === true) { - return 'skipped'; - } - - return spec.status; + return spec.is_skipped ? 'skipped' : spec.status; } function getSpecificationStats(specIndex, auditData) { @@ -79,11 +73,13 @@ const status = getSpecificationStatus(specIndex, auditData); if (status === 'skipped') { - return "Skipped because no applicable entities were found and the cardinality is OPTIONAL or PROHIBITED"; + return "Skipped because no applicable entities were found and the cardinality is OPTIONAL"; } if (status === false) { // Failed - if (spec.total_applicable === 0) { + if (spec.cardinality === 'prohibited') { + return `Failed because ${spec.total_applicable} prohibited entities were found`; + } else if (spec.total_applicable === 0) { return "Failed because no applicable entities were found but the cardinality is REQUIRED"; } else { const failedChecks = spec.total_checks - spec.total_checks_pass; @@ -240,19 +236,30 @@ {#if "@description" in spec}

{spec["@description"]}

{/if} +
+ {#if spec.applicability["@minOccurs"] === 1 && spec.applicability["@maxOccurs"] === 'unbounded'} + Required + {/if} + {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 'unbounded'} + Optional + {/if} + {#if spec.applicability["@minOccurs"] === 0 && spec.applicability["@maxOccurs"] === 0} + Prohibited + {/if} + {#if auditReport} + {@const stats = getSpecificationStats(index, auditReport.data)} + {@const status = getSpecificationStatus(index, auditReport.data)} + {#if stats && spec.applicability["@maxOccurs"] !== 0 && status !== 'skipped'} + Checks: {stats.checksPassed}/{stats.checksTotal} + Requirements: {stats.requirementsPassed}/{stats.requirements} + {/if} + {/if} +
{#if auditReport} {@const reason = getSpecificationReason(index, auditReport.data)} {#if reason}

{reason}

{/if} - {@const stats = getSpecificationStats(index, auditReport.data)} - {@const status = getSpecificationStatus(index, auditReport.data)} - {#if stats && status !== 'skipped'} -
- Checks: {stats.checksPassed}/{stats.checksTotal} - Requirements: {stats.requirementsPassed}/{stats.requirements} -
- {/if} {/if}
@@ -292,9 +299,105 @@ {/if} {/each}
+ + {#if auditReport} + {@const status = getSpecificationStatus(index, auditReport.data)} + {#if ! status && spec.applicability["@maxOccurs"] == 0} + {@const specReport = auditReport.data.specifications[index]} +
+ {#if specReport.applicable_entities && specReport.applicable_entities.length > 0} +
+

Failed Elements ({specReport.applicable_entities.length})

+
+ + + + + + + + + + + + + + + {#each specReport.applicable_entities.slice(0, 10) as entity} + + + + + + + + + + {/each} + {#if specReport.applicable_entities.length > 10} + + + + {/if} + +
ClassPredefinedTypeNameDescriptionWarningGlobalIdTag
{entity.class}{entity.predefined_type || '-'} + + +
{entity.name || '-'}
+
+ +

{entity.name || '-'}

+
+
+
+ + +
{entity.description || '-'}
+
+ +

{entity.description || '-'}

+
+
+
+ + +
{entity.reason || '-'}
+
+ +

{entity.reason || '-'}

+
+
+
+ + +
{entity.global_id || '-'}
+
+ +

{entity.global_id || '-'}

+
+
+
+ + +
{entity.tag || '-'}
+
+ +

{entity.tag || '-'}

+
+
+
... {specReport.applicable_entities.length - 10} more failing elements not shown ...
+
+
+
+ {/if} +
+ + {/if} + {/if} + {#if Array.isArray(spec.requirements) && spec.requirements.length > 0}

Requirements

@@ -497,6 +600,7 @@ {/each}
+ {/if} {/if} @@ -1128,4 +1232,4 @@ white-space: nowrap; cursor: pointer; } - \ No newline at end of file +