diff --git a/src/bonsai/bonsai/bim/data/assets/patterns.svg b/src/bonsai/bonsai/bim/data/assets/patterns.svg index 11be0ee021..1f62673bda 100644 --- a/src/bonsai/bonsai/bim/data/assets/patterns.svg +++ b/src/bonsai/bonsai/bim/data/assets/patterns.svg @@ -1,85 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + - + - + @@ -94,16 +117,19 @@ + + + @@ -112,22 +138,23 @@ - + + - + - + diff --git a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc index a516b9765d..70d6049ad7 100644 --- a/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc +++ b/src/bonsai/bonsai/bim/data/pset/EPset_Drawing.ifc @@ -13,10 +13,10 @@ DATA; #6=IFCSIMPLEPROPERTYTEMPLATE('0AK5C2UpL4$eaac2LszAx$',$,'HasUnderlay','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #7=IFCSIMPLEPROPERTYTEMPLATE('2j2ZEZR8X5tONm7kli5hM6',$,'HasLinework','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #8=IFCSIMPLEPROPERTYTEMPLATE('1ttChRysH9UuEX2FeMj5Hu',$,'HasAnnotation','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); -#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); +#9=IFCSIMPLEPROPERTYTEMPLATE('2NPPxuABv1huDTVh32TFgw',$,'GlobalReferencing','Whether or not this drawing can be referenced in other drawings.',.P_SINGLEVALUE.,'IfcBoolean',$,$,$,$,$,.READWRITE.); #10=IFCSIMPLEPROPERTYTEMPLATE('10hT_1zrzEbRRKMXYAWvtD',$,'Metadata','Comma separated list of selector expressions to evaluate for each drawing elementand add results to their ''class'' attribute.\X2\000A\X0\E.g. ''Name, id'' would add to ''class'' value similar to ''Name-Wall id-1220''.\X2\000A\X0\Then it can be used to applied css styles based on the resulting class.\X2\000A\X0\If attribute is not present on the element, then it won''t be added to it''s ''class''.',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); -#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); -#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude ifc elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#11=IFCSIMPLEPROPERTYTEMPLATE('3Z0BXPSG5CWgtI33ioV7aj',$,'Include','Selector expression to include elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); +#12=IFCSIMPLEPROPERTYTEMPLATE('1RVts_g3PAw98PJA2yL3bO',$,'Exclude','Selector expression to exclude elements in the drawing',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #13=IFCSIMPLEPROPERTYTEMPLATE('0c1$8NpYDEaBiJrj16jHIo',$,'Stylesheet','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #14=IFCSIMPLEPROPERTYTEMPLATE('3mRF52q81FQB$h4oTh7M45',$,'Markers','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); #15=IFCSIMPLEPROPERTYTEMPLATE('1rhr_0N3LDtuORcEJP0KXM',$,'Symbols','',.P_SINGLEVALUE.,'IfcText',$,$,$,$,$,.READWRITE.); diff --git a/src/bonsai/bonsai/bim/export_ifc.py b/src/bonsai/bonsai/bim/export_ifc.py index 5fe5a1df97..4f9692b49c 100644 --- a/src/bonsai/bonsai/bim/export_ifc.py +++ b/src/bonsai/bonsai/bim/export_ifc.py @@ -36,6 +36,7 @@ from bonsai.bim.ifc import IfcStore from mathutils import Vector from typing import Union from logging import Logger +from math import radians class IfcExporter: @@ -104,7 +105,17 @@ class IfcExporter: def sync_object_placement(self, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: element = self.file.by_id(tool.Blender.get_object_bim_props(obj).ifc_definition_id) - if tool.Geometry.is_scaled(obj): + # Handle camera scales specially + if obj.type == "CAMERA": + # Check if this is a reflected ceiling plan camera + camera = tool.Ifc.get_entity(obj) + if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": + # Ensure reflected ceiling cameras have the correct scale + if obj.scale != (-1, -1, -1): + obj.scale = (-1, -1, -1) + obj.rotation_euler = (0.0, 0.0, radians(180)) + # Skip all other scale handling for cameras + elif tool.Geometry.is_scaled(obj): bpy.ops.bim.update_representation(obj=obj.name) # update_representation might not apply scale if the object has openings # reset it, so let user know that the scale wasn't saved. diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 6e902e76f5..54147c9e34 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -94,6 +94,9 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): + aggregates_to_check = set() + + # First pass: unassign all parts and track their aggregates for obj in tool.Blender.get_selected_objects(): element = tool.Ifc.get_entity(obj) if not element: @@ -101,6 +104,10 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): aggregate = ifcopenshell.util.element.get_aggregate(element) if not aggregate: continue + + # Track this aggregate for later checking + aggregates_to_check.add(aggregate) + core.unassign_object( tool.Ifc, tool.Aggregate, @@ -116,6 +123,29 @@ class BIM_OT_aggregate_unassign_object(bpy.types.Operator, tool.Ifc.Operator): pset = tool.Ifc.get().by_id(pset["id"]) ifcopenshell.api.pset.remove_pset(tool.Ifc.get(), product=element, pset=pset) + # Second pass: delete aggregates that now have no parts + deleted_aggregates = [] + for aggregate in aggregates_to_check: + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj: + ifcopenshell.api.root.remove_product(tool.Ifc.get(), product=aggregate) + bpy.data.objects.remove(aggregate_obj, do_unlink=True) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report( + {"INFO"}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts" + ) + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({"INFO"}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + class BIM_OT_enable_editing_aggregate(bpy.types.Operator): """Enable editing aggregation relationship""" diff --git a/src/bonsai/bonsai/bim/module/debug/operator.py b/src/bonsai/bonsai/bim/module/debug/operator.py index 3b527b198b..0820d5e3d6 100644 --- a/src/bonsai/bonsai/bim/module/debug/operator.py +++ b/src/bonsai/bonsai/bim/module/debug/operator.py @@ -818,7 +818,12 @@ class PurgeUnusedObjects(bpy.types.Operator, tool.Ifc.Operator): class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.merge_identical_objects" bl_label = "Merge Identical Objects" - bl_description = "For materials currently only IfcMaterials are supported" + bl_description = ( + "Merge identical IFC objects (that match all attributes).\n" + "\n" + "SHIFT + CLICK to merge by name/identification attribute only.\n" + "Merges names with number suffix, as well (ex: foo, foo.001, foo.002)\n" + ) bl_options = {"REGISTER", "UNDO"} object_type: bpy.props.EnumProperty( # pyright: ignore[reportRedeclaration] @@ -826,18 +831,36 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): items=((s, s.capitalize(), "") for s in get_args(tool.Debug.PurgeMergeObjectType)), ) + by_name_or_identification_only: bpy.props.BoolProperty( + name="By Name/Identification Only", + description="Merge based only on Name or Identification attribute, ignoring other properties", + default=False, + ) + if TYPE_CHECKING: object_type: tool.Debug.PurgeMergeObjectType + def invoke(self, context, event): + # Check if shift key is pressed + if event.shift: + self.by_name_or_identification_only = True + else: + self.by_name_or_identification_only = False + + return self.execute(context) + def _execute(self, context): object_type: str = self.object_type if object_type in ("PROFILE", "TYPE"): self.report({"ERROR"}, f"Unsupported object type {object_type}.") return {"CANCELLED"} - merged_data = tool.Debug.merge_identical_objects(object_type) + merged_data = tool.Debug.merge_identical_objects( + object_type, by_name_or_identification_only=self.by_name_or_identification_only + ) plural_object_type = f"{object_type.lower().replace('_', ' ')}s" if merged_data: + merge_mode = " by name/identification" if self.by_name_or_identification_only else "" for element_type, element_names in merged_data.items(): print(f"- {element_type}:") for name in element_names: @@ -846,7 +869,8 @@ class MergeIdenticalObjects(bpy.types.Operator, tool.Ifc.Operator): merged = sum(len(v) for v in merged_data.values()) msg = " See system console for details." if merged else "" - self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged.{msg}") + merge_mode = " (by name/identification)" if self.by_name_or_identification_only else "" + self.report({"INFO"}, f"{merged} identical {plural_object_type} were merged{merge_mode}.{msg}") if merged == 0: return diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 18bf31363b..4bc3067de8 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -442,6 +442,8 @@ class CreateDrawing(bpy.types.Operator): context.scene.render.filepath = str(Path(svg_path).with_suffix(".png")) assert (drawing_style := self.cprops.get_active_drawing_style()) + tool.Blender.sync_render_visibility() + if drawing_style.render_type == "DEFAULT": bpy.ops.render.render(write_still=True) else: @@ -625,7 +627,7 @@ class CreateDrawing(bpy.types.Operator): path.attrib["d"] = d group.append(g) - def generate_wall_layers(self, context: bpy.types.Context, root) -> None: + def generate_material_layers(self, context: bpy.types.Context, root) -> None: for el in root.findall(".//{http://www.w3.org/2000/svg}g[@{http://www.ifcopenshell.org/ns}guid]"): if "projection" in el.get("class", "").split(): continue @@ -794,8 +796,9 @@ class CreateDrawing(bpy.types.Operator): edge_bm.to_mesh(edge_mesh) edge_bm.free() - actual_path = svg_path[0:-4] + "0001.svg" + freestyle_svg_exporter = tool.Blender.get_addon("freestyle_svg_exporter") context.scene.render.filepath = svg_path[0:-4] + actual_path = freestyle_svg_exporter.create_path(bpy.context.scene) bpy.ops.render.render(write_still=False) os.replace(actual_path, svg_path) @@ -839,7 +842,8 @@ class CreateDrawing(bpy.types.Operator): if tool.Drawing.is_camera_orthographic(): self.generate_bisect_linework(context, root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) @@ -937,12 +941,14 @@ class CreateDrawing(bpy.types.Operator): if self.cprops.cut_mode == "BISECT": self.remove_cut_linework(root) self.generate_bisect_linework(context, root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) elif self.cprops.cut_mode == "OPENCASCADE": self.move_projection_to_bottom(root) - self.generate_wall_layers(context, root) + if self.cprops.generate_material_layers: + self.generate_material_layers(context, root) self.merge_linework_and_add_metadata(root) self.move_elements_to_top(root) @@ -1348,7 +1354,7 @@ class CreateDrawing(bpy.types.Operator): join_criteria = join_criteria.split(",") else: # Drawing convention states that same objects classes with the same material are merged when cut. - join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "Material.Name"] + join_criteria = ["class", "material.Name", "/Pset_.*Common/.Status", "EPset_Status.Status", "EPset_Status.UserDefinedStatus"] group = root.find("{http://www.w3.org/2000/svg}g") joined_paths = {} @@ -1477,11 +1483,10 @@ class CreateDrawing(bpy.types.Operator): joined_paths.setdefault(hash_keys, []).append(el) for key, els in joined_paths.items(): - polygons = [] - classes = set() + queue = [] for el in els: - classes.update(el.attrib["class"].split()) + classes = set(el.attrib["class"].split()) classes.add(el.attrib["{http://www.ifcopenshell.org/ns}guid"]) is_closed_polygon = False for path in el.findall("{http://www.w3.org/2000/svg}path"): @@ -1496,31 +1501,30 @@ class CreateDrawing(bpy.types.Operator): coords.append(coords[0]) if len(coords) > 2 and coords[0] == coords[-1]: is_closed_polygon = True - polygons.append(shapely.Polygon(coords)) + queue.append((shapely.Polygon(coords), classes)) if is_closed_polygon: el.getparent().remove(el) - try: - merged_polygons = shapely.ops.unary_union(polygons) - except: - print("Warning. Portions of the merge failed. Please report a bug!", polygons) - merged_polygons = polygons + while queue: + polygon, polygon_classes = queue.pop() + for polygon2, polygon2_classes in queue[:]: + try: + merged_polygon = shapely.union(polygon, polygon2) + except: + print("Warning. Portions of the merge failed. Please report a bug!", polygon, polygon2) + continue + if type(merged_polygon) == shapely.Polygon: + polygon = merged_polygon + polygon_classes.update(polygon2_classes) + queue.remove((polygon2, polygon2_classes)) - if type(merged_polygons) == shapely.MultiPolygon: - merged_polygons = merged_polygons.geoms - elif type(merged_polygons) == shapely.Polygon: - merged_polygons = [merged_polygons] - else: - merged_polygons = [] - - for polygon in merged_polygons: g = etree.Element("g") path = etree.SubElement(g, "path") d = "M" + " L".join([",".join([str(o) for o in co]) for co in polygon.exterior.coords[0:-1]]) + " Z" for interior in polygon.interiors: d += " M" + " L".join([",".join([str(o) for o in co]) for co in interior.coords[0:-1]]) + " Z" path.attrib["d"] = d - g.set("class", " ".join(list(classes))) + g.set("class", " ".join(list(polygon_classes))) group.append(g) def drawing_to_model_co(self, x: float, y: float) -> Vector: @@ -1862,13 +1866,15 @@ class AddDrawingToSheet(bpy.types.Operator, tool.Ifc.Operator): @classmethod def poll(cls, context): props = tool.Drawing.get_document_props() - # Won't be visible in UI anyway. - prefs = tool.Blender.get_addon_preferences() - if not props.sheets or not prefs.data_dir: - return False if not tool.Drawing.get_active_drawing_item(): cls.poll_message_set("No drawing selected.") return False + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + if not tool.Blender.get_user_data_dir(): + cls.poll_message_set("BIM data directory not set.") + return False return True def _execute(self, context): @@ -2222,6 +2228,7 @@ class ActivateModel(bpy.types.Operator): ) tool.Blender.reset_object_visibility() + tool.Drawing.hide_all_drawing_collections() tool.Blender.update_viewport() bonsai.bim.handler.refresh_ui_data() @@ -2318,9 +2325,8 @@ class ActivateDrawingBase(tool.Ifc.Operator): dprops.active_drawing_id = self.drawing dprops.drawing_styles.clear() - if ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "HasUnderlay"): - bpy.ops.bim.reload_drawing_styles() - bpy.ops.bim.activate_drawing_style() + bpy.ops.bim.reload_drawing_styles() + bpy.ops.bim.activate_drawing_style() if tool.Drawing.is_camera_orthographic(): core.sync_references(tool.Ifc, tool.Collector, tool.Drawing, drawing=tool.Ifc.get().by_id(self.drawing)) @@ -2333,10 +2339,21 @@ class ActivateDrawingBase(tool.Ifc.Operator): camera = context.scene.camera assert camera camera_props = tool.Drawing.get_camera_props(camera) + # Check if this is a reflected ceiling camera and preserve its scale + camera_element = tool.Ifc.get_entity(camera) + is_reflected = False + if camera_element: + is_reflected = ifcopenshell.util.element.get_pset(camera_element, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW" + if is_reflected and camera.scale != (-1, -1, -1): + camera.scale = (-1, -1, -1) + camera.rotation_euler = (0.0, 0.0, radians(180)) + if camera_props.update_representation(camera.matrix_world): bpy.ops.bim.update_representation(obj=camera.name, ifc_representation_class="") - # See 6452 and 6478. - # bpy.ops.bim.refresh_clipping_planes("INVOKE_DEFAULT") + # Restore the scale after update if needed + if is_reflected: + camera.scale = (-1, -1, -1) + camera.rotation_euler = (0.0, 0.0, radians(180)) return {"FINISHED"} @@ -2812,8 +2829,13 @@ class AddScheduleToSheet(bpy.types.Operator, tool.Ifc.Operator): if not props.schedules: cls.poll_message_set("No schedule selected.") return False - prefs = tool.Blender.get_addon_preferences() - return props.schedules and props.sheets and prefs.data_dir + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + if not tool.Blender.get_user_data_dir(): + cls.poll_message_set("BIM data directory not set.") + return False + return True def _execute(self, context): props = tool.Drawing.get_document_props() @@ -2880,8 +2902,13 @@ class AddReferenceToSheet(bpy.types.Operator, tool.Ifc.Operator): if not props.references: cls.poll_message_set("No reference selected.") return False - bim_props = tool.Blender.get_bim_props() - return props.references and props.sheets and bim_props.data_dir + if not props.sheets: + cls.poll_message_set("No sheets available.") + return False + if not tool.Blender.get_user_data_dir(): + cls.poll_message_set("BIM data directory not set.") + return False + return True def _execute(self, context): props = tool.Drawing.get_document_props() diff --git a/src/bonsai/bonsai/bim/module/drawing/prop.py b/src/bonsai/bonsai/bim/module/drawing/prop.py index fb5c05679a..0388f0387a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/prop.py +++ b/src/bonsai/bonsai/bim/module/drawing/prop.py @@ -498,6 +498,11 @@ class BIMCameraProperties(PropertyGroup): name="Linework Mode", update=get_update_layer_callback("linework_mode", "LineworkMode"), ) + generate_material_layers: bpy.props.BoolProperty( + name="Generate Material Layers", + description="Generate material layer linework in drawings", + default=True + ) fill_mode: EnumProperty( items=[ ("NONE", "None", "Disable filling areas seen in projection"), diff --git a/src/bonsai/bonsai/bim/module/drawing/ui.py b/src/bonsai/bonsai/bim/module/drawing/ui.py index 905bc8e287..45e7acec97 100644 --- a/src/bonsai/bonsai/bim/module/drawing/ui.py +++ b/src/bonsai/bonsai/bim/module/drawing/ui.py @@ -99,12 +99,13 @@ class BIM_PT_camera(Panel): row = self.layout.row() row.prop(props, "linework_mode") + row = self.layout.row() + row.prop(props, "generate_material_layers") if props.linework_mode == "OPENCASCADE": row = self.layout.row() row.prop(props, "fill_mode") row = self.layout.row() row.prop(props, "cut_mode") - row = self.layout.row() row.prop(props, "width") row = self.layout.row() diff --git a/src/bonsai/bonsai/bim/module/drawing/workspace.py b/src/bonsai/bonsai/bim/module/drawing/workspace.py index bf2f026358..901c67342c 100644 --- a/src/bonsai/bonsai/bim/module/drawing/workspace.py +++ b/src/bonsai/bonsai/bim/module/drawing/workspace.py @@ -299,6 +299,8 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): return related_objects = bpy.context.selected_objects + created_objects = [] + for related_object in related_objects: obj = core.add_annotation( tool.Ifc, @@ -312,6 +314,14 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): enable_editing=False, ) tool.Drawing.setup_annotation_object(obj, object_type, related_object) + created_objects.append(obj) + + # Select the created annotation objects + bpy.ops.object.select_all(action='DESELECT') + for obj in created_objects: + obj.select_set(True) + if created_objects: + bpy.context.view_layer.objects.active = created_objects[-1] def hotkey_S_A(self): if bpy.ops.bim.add_annotation.poll(): diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index bba979af30..385df0c749 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -109,8 +109,10 @@ def block_scale(scene: bpy.types.Scene) -> None: if obj.type == "CAMERA": camera = tool.Ifc.get_entity(obj) if ifcopenshell.util.element.get_pset(camera, "EPset_Drawing", "TargetView") == "REFLECTED_PLAN_VIEW": - obj.scale = (-1, -1, -1) - obj.rotation_euler = (0.0, 0.0, math.radians(180)) + # Only update if scale isn't already (-1, -1, -1) + if obj.scale != (-1, -1, -1): + obj.scale = (-1, -1, -1) + obj.rotation_euler = (0.0, 0.0, math.radians(180)) else: if obj.scale != (1, 1, 1): obj.scale = (1, 1, 1) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 401428cddb..578dc8b4e9 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -787,7 +787,6 @@ def calc_delete_is_batch(ifc_file: ifcopenshell.file, context: bpy.types.Context is_batch = total_elements > 500000 and total_polygons > 2000 return is_batch - class OverrideDelete(bpy.types.Operator): bl_idname = "bim.override_object_delete" bl_label = "IFC Delete" @@ -858,6 +857,10 @@ class OverrideDelete(bpy.types.Operator): objects_to_remove = context.selected_objects self.process_arrays(context) + + # Track aggregates before deleting their parts + aggregates_to_check = self.track_aggregates(objects_to_remove) + clear_active_object = True for i, obj in enumerate(objects_to_remove, 1): @@ -882,13 +885,25 @@ class OverrideDelete(bpy.types.Operator): continue if ifcopenshell.util.element.get_pset(element, "BBIM_Array"): self.report({"INFO"}, "Elements that are part of an array cannot be deleted.") - return {"FINISHED"} + continue + if element.is_a("IfcGridAxis"): + # Deleting the last W axis is OK + if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( + (grid := element.PartOfV) and len(grid[0].VAxes) == 1 + ): + self.report( + {"INFO"}, "The last grid axis of a grid cannot be deleted. Delete the grid instead." + ) + continue tool.Geometry.delete_ifc_object(obj) elif tool.Geometry.is_representation_item(obj): tool.Geometry.delete_ifc_item(obj) else: bpy.data.objects.remove(obj) + # Delete empty aggregates after deleting their parts + self.delete_empty_aggregates(aggregates_to_check) + for opening in tool.Model.get_model_props().openings: if opening.obj is not None and not tool.Ifc.get_entity(opening.obj): bpy.data.objects.remove(opening.obj) @@ -921,6 +936,47 @@ class OverrideDelete(bpy.types.Operator): data["old_file"].redo() tool.Ifc.set(data["new_file"]) + def track_aggregates(self, objects_to_remove): + """Track aggregates that contain objects being deleted""" + aggregates_to_check = set() + for obj in objects_to_remove: + if not tool.Blender.is_valid_data_block(obj): + continue + element = tool.Ifc.get_entity(obj) + if not element: + continue + aggregate = ifcopenshell.util.element.get_aggregate(element) + if aggregate: + aggregates_to_check.add(aggregate) + return aggregates_to_check + + def delete_empty_aggregates(self, aggregates_to_check): + """Delete aggregates that now have no parts""" + deleted_aggregates = [] + for aggregate in aggregates_to_check: + # Check if aggregate still exists (might have been deleted already) + try: + aggregate.id() + except: + continue + + related_objects = ifcopenshell.util.element.get_parts(aggregate) + if len(related_objects) == 0: + aggregate_name = aggregate.Name or f"{aggregate.is_a()} #{aggregate.id()}" + deleted_aggregates.append(aggregate_name) + + aggregate_obj = tool.Ifc.get_object(aggregate) + if aggregate_obj and tool.Blender.is_valid_data_block(aggregate_obj): + tool.Geometry.delete_ifc_object(aggregate_obj) + + # Show info message if aggregates were deleted + if deleted_aggregates: + if len(deleted_aggregates) == 1: + self.report({'INFO'}, f"Aggregate '{deleted_aggregates[0]}' was deleted because it had no remaining parts") + else: + aggregate_list = ", ".join(f"'{name}'" for name in deleted_aggregates) + self.report({'INFO'}, f"Aggregates {aggregate_list} were deleted because they had no remaining parts") + def process_arrays(self, context: bpy.types.Context) -> None: ifc_file = tool.Ifc.get() selected_objects = set(context.selected_objects) @@ -946,7 +1002,6 @@ class OverrideDelete(bpy.types.Operator): else: break # allows to remove only n last layers of an array - class SelectedIdsData(NamedTuple): objects: set[bpy.types.Object] collections: set[bpy.types.Collection] @@ -2973,6 +3028,7 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): item_mesh = bpy.data.meshes.new("tmp") tool.Ifc.link(item, item_mesh) item_obj = bpy.data.objects.new("tmp", item_mesh) + tool.Geometry.lock_scale(item_obj) tool.Geometry.name_item_object(item_obj, item) item_obj.matrix_world = obj.matrix_world bpy.context.collection.objects.link(item_obj) diff --git a/src/bonsai/bonsai/bim/module/light/data.py b/src/bonsai/bonsai/bim/module/light/data.py index 55bea350d2..abe9d9e87d 100644 --- a/src/bonsai/bonsai/bim/module/light/data.py +++ b/src/bonsai/bonsai/bim/module/light/data.py @@ -41,7 +41,7 @@ class SolarData: @classmethod def sun_position(cls): - return tool.Blender.get_sun_position_addon() + return tool.Blender.get_addon("sun_position") @classmethod def sites(cls): diff --git a/src/bonsai/bonsai/bim/module/light/operator.py b/src/bonsai/bonsai/bim/module/light/operator.py index f57ec3a4e0..4a8d993e0e 100644 --- a/src/bonsai/bonsai/bim/module/light/operator.py +++ b/src/bonsai/bonsai/bim/module/light/operator.py @@ -202,7 +202,7 @@ class RadianceRender(bpy.types.Operator): print(f"Camera position: {camera_position}") print(f"Camera direction: {camera_direction}") - # sun_position = tool.Blender.get_sun_position_addon() + # sun_position = tool.Blender.get_addon("sun_position") # azimuth, elevation = sun_position.sun_calc.get_sun_coordinates( # sun_pos_props.time, # sun_pos_props.latitude, diff --git a/src/bonsai/bonsai/bim/module/light/prop.py b/src/bonsai/bonsai/bim/module/light/prop.py index 54e5f6f73c..1cb36f82eb 100644 --- a/src/bonsai/bonsai/bim/module/light/prop.py +++ b/src/bonsai/bonsai/bim/module/light/prop.py @@ -40,7 +40,7 @@ from bpy.types import PropertyGroup from bonsai.bim.module.light.data import SolarData from bonsai.bim.module.light.decorator import SolarDecorator -sun_position = tool.Blender.get_sun_position_addon() +sun_position = tool.Blender.get_addon("sun_position") now = datetime.datetime.now() with open(os.path.join(os.path.dirname(__file__), "spectraldb.json"), "r") as f: diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index 7245dfb443..dece203282 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -373,7 +373,12 @@ class BIMStairProperties(PropertyGroup): if self.stair_type != "WOOD/STEEL" and self.nosing_length < 0: self["nosing_length"] = 0 - non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type") + def update_custom_tread_lock(self, context: bpy.types.Context) -> None: + """When lock is enabled, sync custom treads with tread_run""" + if self.custom_tread_lock: + self["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + + non_si_units_props = ("is_editing", "number_of_treads", "has_top_nib", "stair_type", "custom_tread_lock") is_editing: bpy.props.BoolProperty(default=False) width: bpy.props.FloatProperty(name="Width", default=1.2, soft_min=0.01, subtype="DISTANCE") @@ -407,6 +412,12 @@ class BIMStairProperties(PropertyGroup): default="CONCRETE", update=validate_nosing_value, ) + custom_tread_lock: bpy.props.BoolProperty( + name="Lock First/Last Treads to Tread Run", + description="When enabled, first and last treads automatically use the Tread Run value", + default=True, + update=update_custom_tread_lock, + ) custom_first_last_tread_run: bpy.props.FloatVectorProperty( name="Custom First / Last Treads Widths", description='Specify custom first / last treads widths, different from the general "Tread Run". Leave 0 to disable.', @@ -442,6 +453,7 @@ class BIMStairProperties(PropertyGroup): top_slab_depth: float has_top_nib: bool stair_type: str + custom_tread_lock: bool custom_first_last_tread_run: tuple[float, float] nosing_length: float nosing_depth: float @@ -480,17 +492,38 @@ class BIMStairProperties(PropertyGroup): } stair_kwargs.update(generic_props) - # defined here to appear last in UI - stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run + # If locked, use tread_run for both first and last treads + if self.custom_tread_lock: + stair_kwargs["custom_first_last_tread_run"] = (self.tread_run, self.tread_run) + else: + stair_kwargs["custom_first_last_tread_run"] = self.custom_first_last_tread_run if not convert_to_project_units: return stair_kwargs stair_kwargs = tool.Model.convert_data_to_project_units(stair_kwargs, self.non_si_units_props) return stair_kwargs + + def get_props_kwargs_for_ifc_export(self, convert_to_project_units=False, stair_type=None): + """Get props including custom_tread_lock for saving to IFC""" + stair_kwargs = self.get_props_kwargs(convert_to_project_units, stair_type) + # Add the lock state for IFC storage (after getting base kwargs to avoid passing to generate function) + stair_kwargs["custom_tread_lock"] = self.custom_tread_lock + return stair_kwargs def set_props_kwargs_from_ifc_data(self, kwargs): kwargs = tool.Model.convert_data_to_si_units(kwargs, self.non_si_units_props) + + # Determine lock state based on whether custom treads match tread_run + # If custom_tread_lock wasn't saved (old files), infer it from the data + if "custom_tread_lock" not in kwargs: + custom_treads = kwargs.get("custom_first_last_tread_run", (0.0, 0.0)) + tread_run = kwargs.get("tread_run", 0.3) + # Lock is off if either custom tread differs from tread_run and is not 0 + kwargs["custom_tread_lock"] = not any( + ct != 0.0 and ct != tread_run for ct in custom_treads + ) + for prop_name in kwargs: setattr(self, prop_name, kwargs[prop_name]) diff --git a/src/bonsai/bonsai/bim/module/model/stair.py b/src/bonsai/bonsai/bim/module/model/stair.py index d2b5184c76..71bfa97a9a 100644 --- a/src/bonsai/bonsai/bim/module/model/stair.py +++ b/src/bonsai/bonsai/bim/module/model/stair.py @@ -188,7 +188,8 @@ class AddStair(bpy.types.Operator, tool.Ifc.Operator): props = tool.Model.get_stair_props(obj) ifc_file = tool.Ifc.get() - stair_data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + stair_data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) pset = tool.Pset.get_element_pset(element, "BBIM_Stair") if not pset: pset = ifcopenshell.api.pset.add_pset(ifc_file, product=element, name="BBIM_Stair") @@ -241,7 +242,8 @@ class FinishEditingStair(bpy.types.Operator, tool.Ifc.Operator): assert element props = tool.Model.get_stair_props(obj) - data = props.get_props_kwargs(convert_to_project_units=True) + # Use the special method that includes custom_tread_lock for IFC storage + data = props.get_props_kwargs_for_ifc_export(convert_to_project_units=True) props.is_editing = False regenerate_stair_mesh(obj) tool.Model.add_body_representation(obj) diff --git a/src/bonsai/bonsai/bim/module/model/ui.py b/src/bonsai/bonsai/bim/module/model/ui.py index 0b17f6d9fb..b62672230c 100644 --- a/src/bonsai/bonsai/bim/module/model/ui.py +++ b/src/bonsai/bonsai/bim/module/model/ui.py @@ -302,13 +302,36 @@ class BIM_PT_stair(bpy.types.Panel): row.operator("bim.cancel_editing_stair", icon="CANCEL", text="") row = self.layout.row(align=True) for prop_name in props.get_props_kwargs(): + # Skip custom_tread_lock as it's handled with custom_first_last_tread_run + if prop_name == "custom_tread_lock": + continue + prop_value = getattr(props, prop_name) - if isinstance(prop_value, Iterable) and not isinstance(prop_value, str): + + # Special handling for custom_first_last_tread_run + if prop_name == "custom_first_last_tread_run": + # Draw the lock toggle + row_lock = self.layout.row(align=True) + lock_text = "Lock First/Last Treads" if not props.custom_tread_lock else "Unlock First/Last Treads" + row_lock.prop( + props, + "custom_tread_lock", + text=lock_text, + icon="LOCKED" if props.custom_tread_lock else "UNLOCKED", + ) + + # Only show the custom values input if unlocked + if not props.custom_tread_lock: + prop_readable_name = props.bl_rna.properties[prop_name].name + self.layout.label(text=f"{prop_readable_name}:") + self.layout.prop(props, prop_name, text="") + elif isinstance(prop_value, Iterable) and not isinstance(prop_value, str): prop_readable_name = props.bl_rna.properties[prop_name].name self.layout.label(text=f"{prop_readable_name}:") self.layout.prop(props, prop_name, text="") else: self.layout.prop(props, prop_name) + if prop_name == "height": # Weak but we just want to insert this inside props drawing row_length = self.layout.row(align=True) row_length.prop(props, "total_length_target") diff --git a/src/bonsai/bonsai/bim/module/project/ui.py b/src/bonsai/bonsai/bim/module/project/ui.py index 75d114e204..03b4222008 100644 --- a/src/bonsai/bonsai/bim/module/project/ui.py +++ b/src/bonsai/bonsai/bim/module/project/ui.py @@ -653,4 +653,5 @@ class BIM_PT_purge(Panel): row = layout.row(align=True) row.label(text=f"{object_type.replace('_', ' ').capitalize()}:") row.operator("bim.purge_unused_objects", text="Purge Unused").object_type = object_type - row.operator("bim.merge_identical_objects", text="Merge Identical").object_type = object_type + merge_op = row.operator("bim.merge_identical_objects", text="Merge Identical") + merge_op.object_type = object_type diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 55ffa56a79..a5bf766ae2 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -317,6 +317,7 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): f"Mesh '{obj.data.name}' has loose geometry, loose geometry will be ignored to save mesh to IFC as a tessellation.", ) + representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) element = core.assign_class( tool.Ifc, tool.Collector, @@ -326,13 +327,9 @@ class AssignClass(bpy.types.Operator, tool.Ifc.Operator): predefined_type=predefined_type, should_add_representation=False, ) - representation = tool.Geometry.export_mesh_to_tessellation(obj, ifc_context) ifcopenshell.api.geometry.assign_representation(tool.Ifc.get(), element, representation) bonsai.core.geometry.switch_representation( - tool.Ifc, - tool.Geometry, - obj=obj, - representation=representation, + tool.Ifc, tool.Geometry, obj=obj, representation=representation ) else: diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 0345f4019a..d69147b13e 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -108,10 +108,6 @@ def add_sheet(ifc: type[tool.Ifc], drawing: type[tool.Drawing], titleblock: ifco def regenerate_sheet( drawing: type[tool.Drawing], sheet: ifcopenshell.entity_instance ) -> Union[list[tool.Drawing.SheetWarningType], None]: - warnings = drawing.validate_sheet_files(sheet) - if warnings: - return warnings - titleblock_uri = drawing.get_document_uri(sheet, "TITLEBLOCK") assert titleblock_uri diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index b9b3ae0d49..ee54a2a7fc 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -1506,28 +1506,21 @@ class Blender(bonsai.core.tool.Blender): return bpy.context.preferences.addons[blender_package_name].preferences @classmethod - def get_sun_position_addon(cls) -> Union[types.ModuleType, None]: - # Check if it's installed as legacy Blender addon. + def get_addon(cls, name: str) -> Union[types.ModuleType, None]: import importlib try: - sun_position = importlib.import_module("sun_position") + return importlib.import_module(name) # Legacy Blender addon except ImportError: - sun_position = None - - if sun_position: - return sun_position + pass for package_name in bpy.context.preferences.addons.keys(): - if package_name.endswith(".sun_position"): + if package_name.endswith(f".{name}"): try: - sun_position = importlib.import_module(package_name) - return sun_position + return importlib.import_module(package_name) except ModuleNotFoundError: pass - return sun_position - @classmethod def get_sun_props(cls) -> Union[SunPosProperties, None]: assert (scene := bpy.context.scene) @@ -1889,6 +1882,24 @@ class Blender(bonsai.core.tool.Blender): obj.select_set(True) bpy.context.view_layer.objects.active = previously_active + @classmethod + def sync_render_visibility(cls): + # Doing bpy.ops.object.hide_render_clear_all() or + # bpy.ops.object.isolate_type_render() is extremely slow. + # Hopefully this doesn't crash on Windows, it doesn't crash on Linux. + should_hides = [0 if obj.visible_get() else 1 for obj in bpy.data.objects] + should_hides = np.fromiter(should_hides, dtype=np.uint8, count=len(should_hides)) + bpy.data.objects.foreach_set("hide_render", should_hides) + return # Otherwise... + # for obj in bpy.data.objects: + # if not obj.data: + # continue + # # For speed, check equality prior to change to prevent needless updates + # if (is_visible := obj.visible_get()) and obj.hide_render is True: + # obj.hide_render = False + # elif not is_visible and obj.hide_render is False: + # obj.hide_render = True + @classmethod def hide_objects(cls, objs): previously_selected = {o.name for o in bpy.context.selected_objects} diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 34f471ada4..65d5ae49bd 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -51,9 +51,13 @@ class Collector(bonsai.core.tool.Collector): if tool.Geometry.is_locked(element): 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_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_viewport = True if element.is_a("IfcProject"): if tool.Geometry.is_locked(element): @@ -69,7 +73,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) - obj.hide_viewport = True + 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) @@ -93,7 +98,8 @@ class Collector(bonsai.core.tool.Collector): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) - obj.hide_viewport = True + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif ( tool.Ifc.get_schema() != "IFC2X3" and element.is_a("IfcSpatialElement") @@ -105,7 +111,8 @@ class Collector(bonsai.core.tool.Collector): cls.link_collection_object_safe(collection, obj) project_obj = tool.Ifc.get_object(tool.Ifc.get().by_type("IfcProject")[0]) cls.link_collection_child_safe(tool.Blender.get_object_bim_props(project_obj).collection, collection) - obj.hide_viewport = True + if not tool.Spatial.get_spatial_props().is_visible: + obj.hide_viewport = True elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": if collection := cls._create_own_collection(obj): cls.link_collection_object_safe(collection, obj) diff --git a/src/bonsai/bonsai/tool/debug.py b/src/bonsai/bonsai/tool/debug.py index 079892a9b5..35e877e734 100644 --- a/src/bonsai/bonsai/tool/debug.py +++ b/src/bonsai/bonsai/tool/debug.py @@ -18,6 +18,7 @@ from __future__ import annotations import os +import re import json import bmesh import bpy @@ -33,7 +34,7 @@ import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from mathutils import Vector from collections import defaultdict -from typing import Literal, TYPE_CHECKING, assert_never +from typing import Literal, TYPE_CHECKING, assert_never, Union from collections.abc import Iterable if TYPE_CHECKING: @@ -136,12 +137,30 @@ class Debug(bonsai.core.tool.Debug): "PERSON", "PERSON_AND_ORGANIZATION", ], + by_name_or_identification_only: bool = False, ) -> dict[str, list[str]]: """Merge identical objects. Note that Styles UI (or other UI) should be updated manually after using this method. + + Args: + object_type: The type of object to merge + by_name_or_identification_only: If True, merge based only on Name attribute (or equivalent identifier). + Strips .XXX suffix patterns (e.g., 'foo.001' matches 'foo', 'foo.002'). + For PERSON, uses Identification. For APPLICATION, uses ApplicationFullName. + For PERSON_AND_ORGANIZATION, uses combination of person and organization identifiers. """ + def normalize_name(name: str) -> str: + """Remove .XXX suffix pattern from names (e.g., 'foo.001' -> 'foo')""" + if not name: + return "" + # Match pattern: name ending with .digits + match = re.match(r"^(.+)\.\d+$", name) + if match: + return match.group(1) + return name + def get_hash(element: ifcopenshell.entity_instance) -> int: data = element.get_info_2(include_identifier=False, recursive=True) if object_type == "APPLICATION": @@ -152,6 +171,49 @@ class Debug(bonsai.core.tool.Debug): data["TheOrganization"] = element.TheOrganization.id() return hash(json.dumps(data, sort_keys=True)) + def get_name_key(element: ifcopenshell.entity_instance) -> str: + """Get key based on name/identifier attribute for the given object type""" + if object_type == "STYLE": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "MATERIAL": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "ORGANIZATION": + name = element.Name if element.Name else "" + return normalize_name(name) + elif object_type == "APPLICATION": + name = element.ApplicationFullName if element.ApplicationFullName else "" + return normalize_name(name) + elif object_type == "PERSON": + ident = element.Identification if element.Identification else "" + return normalize_name(ident) + elif object_type == "PERSON_AND_ORGANIZATION": + person_id = element.ThePerson.Identification if element.ThePerson.Identification else "" + org_name = element.TheOrganization.Name if element.TheOrganization.Name else "" + return f"{normalize_name(person_id)}|{normalize_name(org_name)}" + else: + assert_never(object_type) + + def get_element_name(element: ifcopenshell.entity_instance) -> str: + """Get the actual name/identifier from element for sorting purposes""" + if object_type == "STYLE": + return element.Name if element.Name else "" + elif object_type == "MATERIAL": + return element.Name if element.Name else "" + elif object_type == "ORGANIZATION": + return element.Name if element.Name else "" + elif object_type == "APPLICATION": + return element.ApplicationFullName if element.ApplicationFullName else "" + elif object_type == "PERSON": + return element.Identification if element.Identification else "" + elif object_type == "PERSON_AND_ORGANIZATION": + person_id = element.ThePerson.Identification if element.ThePerson.Identification else "" + org_name = element.TheOrganization.Name if element.TheOrganization.Name else "" + return f"{person_id}|{org_name}" + else: + assert_never(object_type) + ifc_file = tool.Ifc.get() merged_element_types: dict[str, list[str]] = {} @@ -179,22 +241,47 @@ class Debug(bonsai.core.tool.Debug): for element_type in element_types: elements = ifc_file.by_type(element_type, include_subtypes=False) - # Calculate hashes. - hash_to_elements: defaultdict[int, list[ifcopenshell.entity_instance]] = defaultdict(list) - for element in elements: - # Except for styles, ignore unnamed elements as they may be not safe to merge - merge_optional_names = ("STYLE", "PERSON") - not_optional_name = ("APPLICATION", "ORGANIZATION") - has_no_name = ("PERSON_AND_ORGANIZATION",) - if ( - object_type not in merge_optional_names - and object_type not in not_optional_name - and object_type not in has_no_name - and not element.Name - ): - continue - element_hash = get_hash(element) - hash_to_elements[element_hash].append(element) + # Calculate hashes or name keys. + hash_to_elements: defaultdict[Union[int, str], list[ifcopenshell.entity_instance]] + + if by_name_or_identification_only: + # Group by name/identifier only (with .XXX suffix normalization) + hash_to_elements = defaultdict(list) + for element in elements: + name_key = get_name_key(element) + # Skip elements without a valid identifier + if not name_key: + continue + hash_to_elements[name_key].append(element) + + # Sort elements within each group to keep the one without suffix (or lowest suffix) + for name_key in hash_to_elements: + # Sort by: 1) prefer names without .XXX suffix, 2) then by original name + def sort_key(el): + name = get_element_name(el) + # Check if name has .XXX suffix + has_suffix = bool(re.match(r"^.+\.\d+$", name)) + # Return tuple: (has_suffix, name) - sort by no suffix first, then alphabetically + return (has_suffix, name) + + hash_to_elements[name_key].sort(key=sort_key) + else: + # Group by full hash + hash_to_elements = defaultdict(list) + for element in elements: + # Except for styles, ignore unnamed elements as they may be not safe to merge + merge_optional_names = ("STYLE", "PERSON") + not_optional_name = ("APPLICATION", "ORGANIZATION") + has_no_name = ("PERSON_AND_ORGANIZATION",) + if ( + object_type not in merge_optional_names + and object_type not in not_optional_name + and object_type not in has_no_name + and not element.Name + ): + continue + element_hash = get_hash(element) + hash_to_elements[element_hash].append(element) merged_elements_names: list[str] = [] # Merge elements. diff --git a/src/bonsai/bonsai/tool/drawing.py b/src/bonsai/bonsai/tool/drawing.py index 336b3fd0dd..c770a9fc8f 100644 --- a/src/bonsai/bonsai/tool/drawing.py +++ b/src/bonsai/bonsai/tool/drawing.py @@ -208,6 +208,25 @@ class Drawing(bonsai.core.tool.Drawing): return obj + @classmethod + def get_annotation_drawing(cls, element: ifcopenshell.entity_instance) -> ifcopenshell.entity_instance | None: + for rel in element.HasAssignments: + if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.ObjectType == "DRAWING": + for e in rel.RelatedObjects: + if e.ObjectType == "DRAWING": + return e + + @classmethod + def exclude_annotation_from_drawing(cls, element: ifcopenshell.entity_instance, drawing: ifcopenshell.entity_instance) -> None: + pset = tool.Pset.get_element_pset(drawing, "EPset_Drawing") + if not pset: + pset = ifcopenshell.api.pset.add_pset(ifc_file, product=drawing, name="EPset_Drawing") + exclude = ifcopenshell.util.element.get_property_definition(pset, prop="Exclude") or "" + if exclude: + exclude += "+" + exclude += element.GlobalId + ifcopenshell.api.pset.edit_pset(tool.Ifc.get(), pset=pset, properties={"Exclude": exclude}) + @classmethod def ensure_annotation_in_drawing_plane( cls, obj: bpy.types.Object, camera: Optional[bpy.types.Object] = None @@ -218,11 +237,7 @@ class Drawing(bonsai.core.tool.Drawing): entity = tool.Ifc.get_entity(obj) if not entity: return - for rel in entity.HasAssignments: - if rel.is_a("IfcRelAssignsToGroup"): - for e in rel.RelatedObjects: - if e.ObjectType == "DRAWING": - return tool.Ifc.get_object(e) + return tool.Ifc.get_object(cls.get_annotation_drawing(entity)) if not camera: camera = get_camera_from_annotation_object(obj) or bpy.context.scene.camera @@ -1401,7 +1416,10 @@ class Drawing(bonsai.core.tool.Drawing): cls, drawing: ifcopenshell.entity_instance ) -> list[ifcopenshell.entity_instance]: elements = [] - existing_references = cls.get_group_elements(cls.get_drawing_group(drawing)) + existing_references = set(cls.get_group_elements(cls.get_drawing_group(drawing))) + if exclude := ifcopenshell.util.element.get_pset(drawing, "EPset_Drawing", "Exclude"): + existing_references.update(ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), exclude)) + for element in tool.Ifc.get().by_type("IfcAnnotation"): if element in existing_references or element == drawing: continue @@ -1411,8 +1429,13 @@ class Drawing(bonsai.core.tool.Drawing): "GlobalReferencing", False ): elements.append(element) - for element in tool.Ifc.get().by_type("IfcGridAxis"): - elements.append(element) + for element in tool.Ifc.get().by_type("IfcGrid"): + if element in existing_references: + continue + for axis in element.UAxes + element.VAxes + (element.WAxes or tuple()): + if axis in existing_references: + continue + elements.append(element) target_view = tool.Drawing.get_drawing_target_view(drawing) if target_view in ("SECTION_VIEW", "ELEVATION_VIEW"): for element in tool.Ifc.get().by_type("IfcBuildingStorey"): @@ -2568,3 +2591,10 @@ class Drawing(bonsai.core.tool.Drawing): ifcopenshell.api.document.remove_reference(tool.Ifc.get(), reference=reference) tool.Drawing.import_sheets() + + @classmethod + def hide_all_drawing_collections(cls) -> None: + for element in tool.Ifc.get().by_type("IfcAnnotation"): + if element.ObjectType == "DRAWING" and (obj := tool.Ifc.get_object(element)): + print('found element', element) + tool.Blender.get_layer_collection(obj.users_collection[0]).hide_viewport = True diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 8db4264783..c31d1e0fbb 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -233,20 +233,20 @@ class Geometry(bonsai.core.tool.Geometry): element = tool.Ifc.get_entity(obj) if not element: return - elif element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": - return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) + elif element.is_a("IfcAnnotation"): + if element.ObjectType == "DRAWING": + return bonsai.core.drawing.remove_drawing(tool.Ifc, tool.Drawing, drawing=element) + elif (referenced_element := tool.Drawing.get_annotation_element(element)) and (drawing := tool.Drawing.get_annotation_drawing(element)): + tool.Drawing.exclude_annotation_from_drawing(referenced_element, drawing) elif element.is_a("IfcRelSpaceBoundary"): ifcopenshell.api.boundary.remove_boundary(ifc_file, boundary=element) tool.Boundary.undecorate_boundary(obj) return bpy.data.objects.remove(obj) elif element.is_a("IfcGridAxis"): - is_last_axis = False # Deleting the last W axis is OK if ((grid := element.PartOfU) and len(grid[0].UAxes) == 1) or ( (grid := element.PartOfV) and len(grid[0].VAxes) == 1 ): - is_last_axis = True - if is_last_axis: return ifcopenshell.api.grid.remove_grid_axis(ifc_file, axis=element) return bpy.data.objects.remove(obj) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 10181c5d93..bdf09ceacd 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -1399,7 +1399,6 @@ class Model(bonsai.core.tool.Model): number_of_risers = number_of_treads + 1 tread_rise = height / number_of_risers - custom_tread_run = any(run != 0 for run in custom_first_last_tread_run) nosing_overlap = max(nosing_length, 0) nosing_tread_gap = -min(nosing_length, 0) nosing_overlap_offset = -V_(nosing_overlap, 0) @@ -1430,19 +1429,26 @@ class Model(bonsai.core.tool.Model): default_tread_offset = Vector([tread_run, tread_rise]) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run - tread_verts = deepcopy(default_tread_verts) - tread_verts[-1].x = current_tread_run - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + + # Handle zero-width treads + if current_tread_run == 0: + # For zero width, just return vertical offset with no horizontal tread + return tread_offset, () + + tread_verts = deepcopy(default_tread_verts) + tread_verts[-1].x = current_tread_run + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # treads @@ -1450,9 +1456,13 @@ class Model(bonsai.core.tool.Model): for i in range(number_of_risers): last_vert_i = len(vertices) - 1 tread_offset, tread_verts = get_tread_data(i) - current_tread_verts = [v + current_offset for v in tread_verts] - edges.extend(default_tread_edges + last_vert_i) - vertices.extend(current_tread_verts) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + current_tread_verts = [v + current_offset for v in tread_verts] + edges.extend(default_tread_edges + last_vert_i) + vertices.extend(current_tread_verts) + current_offset += tread_offset if stair_type == "WOOD/STEEL": @@ -1467,35 +1477,47 @@ class Model(bonsai.core.tool.Model): default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise) def get_tread_data(i): - if custom_tread_run: - current_tread_run = None - if i == 0 and custom_first_last_tread_run[0] != 0: - current_tread_run = custom_first_last_tread_run[0] - elif i == number_of_risers - 1 and custom_first_last_tread_run[1] != 0: - current_tread_run = custom_first_last_tread_run[1] + # Check if this is first or last tread with custom run + current_tread_run = None + if i == 0 and custom_first_last_tread_run[0] is not None: + current_tread_run = custom_first_last_tread_run[0] + elif i == number_of_risers - 1 and custom_first_last_tread_run[1] is not None: + current_tread_run = custom_first_last_tread_run[1] - if current_tread_run: - tread_offset = default_tread_offset.copy() - tread_offset.x = current_tread_run + nosing_tread_gap - tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) - return tread_offset, tread_verts + if current_tread_run is not None: + tread_offset = default_tread_offset.copy() + tread_offset.x = current_tread_run + nosing_tread_gap + + # Handle zero-width treads + if current_tread_run == 0: + return tread_offset, () + + tread_verts = get_tread_verts(size=V_(current_tread_run + nosing_overlap, tread_depth)) + return tread_offset, tread_verts + return default_tread_offset, default_tread_verts # each tread is a separate shape cur_offset = V_(0, 0) + tread_index = 0 for i in range(number_of_risers): tread_offset, tread_verts = get_tread_data(i) - cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] - vertices.extend(cur_trade_shape) + + # Skip adding vertices/edges for zero-width treads + if tread_verts: + cur_trade_shape = [v + cur_offset + nosing_overlap_offset for v in tread_verts] + vertices.extend(cur_trade_shape) - cur_vertex = i * 4 - verts_to_add = ( - (cur_vertex, cur_vertex + 1), - (cur_vertex + 1, cur_vertex + 2), - (cur_vertex + 2, cur_vertex + 3), - (cur_vertex + 3, cur_vertex), - ) - edges.extend(verts_to_add) + cur_vertex = tread_index * 4 + verts_to_add = ( + (cur_vertex, cur_vertex + 1), + (cur_vertex + 1, cur_vertex + 2), + (cur_vertex + 2, cur_vertex + 3), + (cur_vertex + 3, cur_vertex), + ) + edges.extend(verts_to_add) + tread_index += 1 + cur_offset += tread_offset elif stair_type == "GENERIC": diff --git a/src/bonsai/test/tool/test_model.py b/src/bonsai/test/tool/test_model.py index 35d5c6bc57..04fdd881a6 100644 --- a/src/bonsai/test/tool/test_model.py +++ b/src/bonsai/test/tool/test_model.py @@ -176,6 +176,27 @@ class TestStairCalculatedParams(NewFile): calculated_data["Length"] += -0.2 + 0.1 self.compare_data(pset_data, calculated_data) + # zero-width first tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # zero-width last tread + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.3, 0.0) + calculated_data["Length"] = 0.9 # Only 3 treads at 0.3 each + self.compare_data(pset_data, calculated_data) + + # both first and last treads zero-width + pset_data = pset_data_base.copy() + calculated_data = calculated_data_base.copy() + pset_data["custom_first_last_tread_run"] = (0.0, 0.0) + calculated_data["Length"] = 0.6 # Only 2 middle treads at 0.3 each + self.compare_data(pset_data, calculated_data) + # overlap affects stair length only by first tread pset_data = pset_data_base.copy() calculated_data = calculated_data_base.copy() @@ -296,6 +317,94 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_concrete_stair_zero_width_first_tread(self): + """Test concrete stair with zero-width first tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped - goes straight to second tread + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.6, 0, 1.0), + V(0.9, 0, 1.0), + V(0.9, 0, 0.67457), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (8, 0), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_concrete_stair_zero_width_last_tread(self): + """Test concrete stair with zero-width last tread""" + kwargs = { + "base_slab_depth": 0.25, + "has_top_nib": False, + "height": 1.0, + "number_of_treads": 3, + "stair_type": "CONCRETE", + "top_slab_depth": 0.25, + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + # Last tread skipped + V(0.9, 0, 0.67457), + V(0.1, 0, -0.25), + V(0.0, 0, -0.25), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 6), + (6, 7), + (9, 0), + (8, 9), + (7, 8), + ) + edges_data = [e[::-1] for e in edges_data] + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair(self): kwargs = { "height": 1.0, @@ -348,6 +457,100 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_wood_steel_stair_zero_width_first_tread(self): + """Test wood/steel stair with zero-width first tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + # First tread skipped - start at second tread + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.3, 0, 0.5), + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.6, 0, 0.75), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + V(0.9, 0, 0.75), + V(0.9, 0, 1.0), + V(0.6, 0, 1.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + + def test_create_wood_steel_stair_zero_width_last_tread(self): + """Test wood/steel stair with zero-width last tread""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "WOOD/STEEL", + "tread_depth": 0.25, + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + V(0.3, 0, 0.0), + V(0.3, 0, 0.25), + V(0.0, 0, 0.25), + V(0.3, 0, 0.25), + V(0.6, 0, 0.25), + V(0.6, 0, 0.5), + V(0.3, 0, 0.5), + V(0.6, 0, 0.5), + V(0.9, 0, 0.5), + V(0.9, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 0), + (4, 5), + (5, 6), + (6, 7), + (7, 4), + (8, 9), + (9, 10), + (10, 11), + (11, 8), + ) + + faces_data = () + + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair(self): kwargs = {"height": 1.0, "number_of_treads": 3, "stair_type": "GENERIC", "tread_run": 0.3, "width": 1.2} verts_data = ( @@ -381,6 +584,41 @@ class TestGenerateStair2DProfile(NewFile): generated_profile = subject.generate_stair_2d_profile(**kwargs) self.compare_data(generated_profile, expected_profile) + def test_create_generic_stair_zero_width_treads(self): + """Test generic stair with zero-width first and last treads""" + kwargs = { + "height": 1.0, + "number_of_treads": 3, + "stair_type": "GENERIC", + "tread_run": 0.3, + "width": 1.2, + "custom_first_last_tread_run": (0.0, 0.0), + } + verts_data = ( + V(0.0, 0, 0.0), + # First tread skipped + V(0.0, 0, 0.5), + V(0.3, 0, 0.5), + V(0.3, 0, 0.75), + V(0.6, 0, 0.75), + # Last tread skipped + V(0.6, 0, 0.0), + ) + edges_data = ( + (0, 1), + (1, 2), + (2, 3), + (3, 4), + (4, 5), + (5, 0), + ) + edges_data = [e[::-1] for e in edges_data] + + faces_data = () + expected_profile = (verts_data, edges_data, faces_data) + generated_profile = subject.generate_stair_2d_profile(**kwargs) + self.compare_data(generated_profile, expected_profile) + class TestUsingArrays(NewFile): def setup_array(self, add_second_layer=False, sync_children=False): diff --git a/src/ifcopenshell-python/ifcopenshell/util/element.py b/src/ifcopenshell-python/ifcopenshell/util/element.py index 48d65ae7ae..22665772da 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/element.py +++ b/src/ifcopenshell-python/ifcopenshell/util/element.py @@ -565,6 +565,40 @@ def get_predefined_type(element: ifcopenshell.entity_instance) -> Union[str, Non return predefined_type +def is_userdefined_type(element: ifcopenshell.entity_instance) -> bool: + """Checks if the predefined type is userdefined + + :param element: The IFC Element entity + :return: True if userdefined + + Example: + + .. code:: python + + element = ifcopenshell.by_type("IfcWall")[0] + is_userdefined_type = ifcopenshell.util.element.is_userdefined_type(element) + """ + if element_type := get_type(element): + predefined_type = getattr(element_type, "PredefinedType", None) + if predefined_type == "USERDEFINED": + return True + elif not predefined_type: + predefined_type = getattr(element_type, "ElementType", ...) + if predefined_type == ...: + predefined_type = getattr(element_type, "ProcessType", None) + if predefined_type: + return True + if predefined_type and predefined_type != "NOTDEFINED": + return False + + predefined_type = getattr(element, "PredefinedType", None) + if predefined_type == "USERDEFINED": + return True + elif not predefined_type: + return bool(getattr(element, "ObjectType", None)) + return False + + def get_type(element: ifcopenshell.entity_instance) -> Union[ifcopenshell.entity_instance, None]: """Retrieves the construction type element of an element occurrence. diff --git a/src/ifcopenshell-python/test/util/test_element.py b/src/ifcopenshell-python/test/util/test_element.py index 1a1e28d58b..0113e68d2f 100644 --- a/src/ifcopenshell-python/test/util/test_element.py +++ b/src/ifcopenshell-python/test/util/test_element.py @@ -369,6 +369,65 @@ class TestGetPredefinedTypeIFC4(test.bootstrap.IFC4): assert subject.get_predefined_type(element_type) == "NOTDEFINED" +class TestIsUserdefinedTypeIFC4(test.bootstrap.IFC4): + def test_getting_a_predefined_element(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_element_userdefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element.PredefinedType = "USERDEFINED" + element.ObjectType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_element_type_without_a_predefined_type_attribute(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcAnnotation") + element.ObjectType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_inherited_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_inherited_userdefined_type_for_an_element_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "USERDEFINED" + element_type.ElementType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_overriden_predefined_type(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "NOTDEFINED" + element.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element) + + def test_getting_an_inherited_userdefined_type_for_a_process_type(self): + element = ifcopenshell.api.sequence.add_task(self.file) + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcTaskType") + ifcopenshell.api.type.assign_type(self.file, related_objects=[element], relating_type=element_type) + element_type.PredefinedType = "USERDEFINED" + element_type.ProcessType = "FOOBAR" + assert subject.is_userdefined_type(element) + + def test_getting_an_element_type_predefined_type(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element_type.PredefinedType = "PARTITIONING" + assert not subject.is_userdefined_type(element_type) + + def test_getting_an_element_type_null_predefined_type(self): + element_type = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWallType") + element_type.PredefinedType = "NOTDEFINED" + assert not subject.is_userdefined_type(element_type) + + class TestGetTypeIFC4(test.bootstrap.IFC4): def test_getting_the_type_of_a_product(self): element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") diff --git a/src/ifcparse/IfcEntityInstanceData.h b/src/ifcparse/IfcEntityInstanceData.h index 65bdfdf0a2..04e7dcb0ad 100644 --- a/src/ifcparse/IfcEntityInstanceData.h +++ b/src/ifcparse/IfcEntityInstanceData.h @@ -165,7 +165,7 @@ namespace IfcParse { } } -#if IFOPSH_WITH_ROCKSDB +#ifdef IFOPSH_WITH_ROCKSDB namespace impl { diff --git a/src/ifcparse/rocksdb_map_adapter.h b/src/ifcparse/rocksdb_map_adapter.h index a6254356c6..da2346789f 100644 --- a/src/ifcparse/rocksdb_map_adapter.h +++ b/src/ifcparse/rocksdb_map_adapter.h @@ -274,7 +274,7 @@ public: } iterator& operator++() { -#if IFOPSH_WITH_ROCKSDB +#ifdef IFOPSH_WITH_ROCKSDB if (it_) { it_->Next(); check_valid(); diff --git a/src/ifcpatch/ifcpatch/recipes/MergeStyles.py b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py index cb2780a360..5c1fa2f4fa 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeStyles.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeStyles.py @@ -44,11 +44,12 @@ class Patcher(ifcpatch.BasePatcher): uniques = {} i = 0 for element in self.file.by_type(ifc_class): - data = "-".join([str(a) for a in element]) - if unique := uniques.get(data, None): + ifc_class = element.is_a() + key = "-".join([str(a) for a in element]) + if unique := uniques.get(ifc_class, {}).get(key, None): ifcopenshell.util.element.replace_element(element, unique) self.file.remove(element) i += 1 else: - uniques[data] = element + uniques.setdefault(ifc_class, {})[key] = element print(f"Replaced {i} {ifc_class}") diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 97c24c59a1..6b5536299d 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -206,6 +206,12 @@ class Entity(Facet): except: # If the user has specified a class that doesn't exist in the version results = [] + if not self.name.endswith("TYPE"): + try: + for element_type in ifc_file.by_type(f"{self.name}Type"): + results.extend(ifcopenshell.util.element.get_types(element_type)) + except: + pass else: results = [] ifc_classes = [t for t in ifc_file.wrapped_data.types() if t.upper() == self.name] @@ -223,12 +229,25 @@ class Entity(Facet): is_pass = inst.is_a().upper() == self.name reason = None - if not is_pass: + if ( + not is_pass + and inst.file.schema == "IFC2X3" + and not self.name.endswith("TYPE") + and (element_type := ifcopenshell.util.element.get_type(inst)) + ): + is_pass = element_type.is_a().upper() == f"{self.name}TYPE" + reason = {"type": "NAME", "actual": element_type.is_a().upper()[:-4]} + elif not is_pass: reason = {"type": "NAME", "actual": inst.is_a().upper()} if is_pass and self.predefinedType: - predefined_type = ifcopenshell.util.element.get_predefined_type(inst) - is_pass = predefined_type == self.predefinedType + if self.predefinedType == "USERDEFINED": + is_pass = ifcopenshell.util.element.is_userdefined_type(inst) + if not is_pass: + predefined_type = ifcopenshell.util.element.get_predefined_type(inst) + else: + predefined_type = ifcopenshell.util.element.get_predefined_type(inst) + is_pass = predefined_type == self.predefinedType if not is_pass: reason = {"type": "PREDEFINEDTYPE", "actual": predefined_type} diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 10d94b2f03..167201979b 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -172,10 +172,10 @@ class TestEntity: facet = Entity(name="IFCWALL", predefinedType="USERDEFINED") ifc = ifcopenshell.file() run( - "A predefined type must always specify a meaningful type, not USERDEFINED itself", + "A predefined type may specify USERDEFINED itself", facet=facet, inst=ifc.createIfcWall(PredefinedType="USERDEFINED", ObjectType="WALDO"), - expected=False, + expected=True, ) ifc = ifcopenshell.file() @@ -230,6 +230,32 @@ class TestEntity: wall3 = ifcopenshell.api.root.create_entity(ifc, ifc_class="IfcWall", predefined_type="BAZFOO") run("Restrictions an be specified for the predefined type 3/3", facet=facet, inst=wall3, expected=False) + def test_ifc2x3_occurrence_type_mapping(self): + set_facet("entity") + + ifc = ifcopenshell.file(schema="IFC2X3") + application = ifcopenshell.api.owner.add_application(ifc) + person = ifcopenshell.api.owner.add_person( + ifc, identification="LPARTEE", family_name="Partee", given_name="Leeable" + ) + organisation = ifcopenshell.api.owner.add_organisation( + ifc, identification="AWB", name="Architects Without Ballpens" + ) + user = ifcopenshell.api.owner.add_person_and_organisation(ifc, person=person, organisation=organisation) + ifcopenshell.api.owner.settings.get_user = lambda x: user + ifcopenshell.api.owner.settings.get_application = lambda x: application + + element = ifcopenshell.api.root.create_entity(ifc, "IfcFlowTerminal") + element_type = ifcopenshell.api.root.create_entity(ifc, "IfcAirTerminalType") + ifcopenshell.api.type.assign_type(ifc, related_objects=[element], relating_type=element_type) + facet = Entity(name="IFCAIRTERMINAL") + assert facet.filter(ifc) == [element] + run("In IFC2X3 the type class is checked instead 1/2", facet=facet, inst=element, expected=True) + + facet = Entity(name="IFCELECTRICAPPLIANCE") + assert facet.filter(ifc) == [] + run("In IFC2X3 the type class is checked instead 2/2", facet=facet, inst=element, expected=False) + def test_to_string_required_applicability(self): spec = ifctester.ids.Specification(name="Foo", minOccurs=1, maxOccurs="unbounded") facet = Entity(name="IFCWALL")