diff --git a/src/bonsai/bonsai/bim/module/geometry/helper.py b/src/bonsai/bonsai/bim/module/geometry/helper.py index 6a0d2e189b..eaff04eaf4 100644 --- a/src/bonsai/bonsai/bim/module/geometry/helper.py +++ b/src/bonsai/bonsai/bim/module/geometry/helper.py @@ -120,217 +120,6 @@ class Helper: return {"profile": profile, "extrusion": extrusion} - def auto_detect_profiles( - self, obj: bpy.types.Object, mesh: bpy.types.Mesh, position: Matrix | None = None - ) -> Union[tuple, dict]: - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - - if position is None: - position = Matrix() - position_i = position.inverted() - - groups = {"IFCARCINDEX": [], "IFCCIRCLE": []} - for i, group in enumerate(obj.vertex_groups): - if "IFCARCINDEX" in group.name: - groups["IFCARCINDEX"].append(i) - elif "IFCCIRCLE" in group.name: - groups["IFCCIRCLE"].append(i) - - bm = bmesh.new() - bm.from_mesh(mesh) - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) - bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") - - # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess - # This is how we access vertex groups via bmesh, apparently, it's not very intuitive - deform_layer = bm.verts.layers.deform.active - - # Sanity check - group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} - for vert in bm.verts: - total_groups = 0 - is_circle = False - for group_type, group_indices in groups.items(): - if not group_indices: - continue - is_special, group_index = tool.Blender.bmesh_check_vertex_in_groups(vert, deform_layer, group_indices) - if not is_special: - continue - if group_type == "IFCCIRCLE": - is_circle = True - group_verts[group_type].setdefault(group_index, 0) - group_verts[group_type][group_index] += 1 - total_groups += 0 - if total_groups > 1: # A vert can only belong to one group - return (False, "AMBIGUOUS_SPECIAL_VERTEX") - elif is_circle: - pass # Circles are allowed to be unclosed - elif total_groups == 0 and len(vert.link_edges) != 2: # Unclosed loop or forked loop - return (False, "UNCLOSED_LOOP") - - for group_type, group_counts in group_verts.items(): - if group_type == "IFCARCINDEX": - for group_count in group_counts.values(): - if group_count != 3: # Each arc needs 3 verts - return (False, "3POINT_ARC") - elif group_type == "IFCCIRCLE": - for group_count in group_counts.values(): - if group_count != 2: # Each circle needs 2 verts - return (False, "CIRCLE") - - loop_edges = set(bm.edges) - - # Create loops from edges - loops = [] - while loop_edges: - edge = loop_edges.pop() - loop = [edge] - has_found_connected_edge = True - while has_found_connected_edge: - has_found_connected_edge = False - for edge in loop_edges.copy(): - edge_verts = set(edge.verts) - if edge_verts & set(loop[0].verts): - loop.insert(0, edge) - loop_edges.remove(edge) - has_found_connected_edge = True - elif edge_verts & set(loop[-1].verts): - loop.append(edge) - loop_edges.remove(edge) - has_found_connected_edge = True - loops.append(loop) - - tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) - - def is_in_group(v, group_name): - for group_index in groups[group_name]: - if group_index in v[deform_layer]: - return True - return False - - def get_group_index(v, group_name): - for group_index in groups[group_name]: - if group_index in v[deform_layer]: - return group_index - - # Convert all loops into IFC curves - curves = [] - for loop in loops: - - if len(loop) == 1 and all([is_in_group(v, "IFCCIRCLE") for v in loop[0].verts]): - v1, v2 = loop[0].verts - mid = v1.co.lerp(v2.co, 0.5) - mid = (position_i @ (mid / unit_scale)).to_2d() - v1 = (position_i @ (v1.co / unit_scale)).to_2d() - radius = (mid - v1).length - curves.append( - tmp.createIfcCircle(tmp.createIfcAxis2Placement2D(tmp.createIfcCartesianPoint(list(mid))), radius) - ) - else: # For now, assume closed loop - loop_verts = [] - for i, edge in enumerate(loop): - if i == 0: - if edge.verts[0] in loop[i + 1].verts: - loop_verts.append(edge.verts[1]) - loop_verts.append(edge.verts[0]) - elif edge.verts[1] in loop[i + 1].verts: - loop_verts.append(edge.verts[0]) - loop_verts.append(edge.verts[1]) - else: - loop_verts.append(edge.other_vert(loop_verts[-1])) - loop_verts.pop() - - # Handle loop_verts possibly starting halfway through an arc - if (group_index := get_group_index(loop_verts[0], "IFCARCINDEX")) is not None: - if get_group_index(loop_verts[1], "IFCARCINDEX") != group_index: - loop_verts.insert(0, loop_verts.pop()) - loop_verts.insert(0, loop_verts.pop()) - elif get_group_index(loop_verts[2], "IFCARCINDEX") != group_index: - loop_verts.insert(0, loop_verts.pop()) - - if tmp.schema != "IFC2X3" and any([is_in_group(v, "IFCARCINDEX") for v in loop_verts]): - # We need to specify segments - coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] - points = tmp.createIfcCartesianPointList2D(coord_list) - i = 0 - segments = [] - total_verts = len(loop_verts) - while i < total_verts: - v = loop_verts[i] - if ( - i + 1 != total_verts - and is_in_group(v, "IFCARCINDEX") - and is_in_group(loop_verts[i + 1], "IFCARCINDEX") - ): - segments.append(tmp.createIfcArcIndex([i + 1, i + 2, i + 3])) - i += 2 - else: - segments.append(tmp.createIfcLineIndex([i + 1, i + 2])) - i += 1 - # Close the loop - last_segment_indices = list(segments[-1][0]) - last_segment_indices[-1] = 1 - segments[-1][0] = last_segment_indices - curves.append(tmp.createIfcIndexedPolyCurve(points, segments)) - elif tmp.schema == "IFC2X3": - points = [ - tmp.createIfcCartesianPoint(list((position_i @ (v.co / unit_scale)).to_2d())) - for v in loop_verts - ] - points.append(points[0]) - curves.append(tmp.createIfcPolyline(points)) - else: # Pure straight polyline, no segments required - coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] - coord_list.append(coord_list[0]) - points = tmp.createIfcCartesianPointList2D(coord_list) - curves.append(tmp.createIfcIndexedPolyCurve(points)) - - # Sort IFC curves into either closed, or closed with void profile defs - profile_defs = [] - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - - # First convert to Shapely - polygons = {} - for curve in curves: - geometry = ifcopenshell.geom.create_shape(settings, curve) - v = ifcopenshell.util.shape.get_vertices(geometry, is_2d=True) - v = np.round(v, 4) # Round to nearest 0.1mm, otherwise things like circles don't polygonise reliably - edges = ifcopenshell.util.shape.get_edges(geometry) - boundary_lines = [shapely.LineString([v[e[0]], v[e[1]]]) for e in edges] - unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) - closed_polygons = shapely.polygonize(unioned_boundaries.geoms) - for polygon in closed_polygons.geoms: - polygons[curve] = polygon - break - - # Check for contains properly (IFC doesn't allow common boundary points) - outer_inner = {} - inner_outer = {} - for curve, polygon in polygons.items(): - for curve2, polygon2 in polygons.items(): - if curve == curve2: - continue - if polygon.contains_properly(polygon2): - outer_inner.setdefault(curve, []).append(curve2) - inner_outer.setdefault(curve2, []).append(curve) - - # Odd-even rule for nested curves - nested_level = {c: len(inner_outer[c]) if c in inner_outer else 0 for c in curves} - for curve in sorted(curves, key=lambda c: nested_level[c]): - level = nested_level[curve] - if level % 2 == 0: - if curve in outer_inner: - inners = [c for c in outer_inner[curve] if nested_level[c] == level + 1] - profile_defs.append(tmp.createIfcArbitraryProfileDefWithVoids("AREA", None, curve, inners)) - else: - profile_defs.append(tmp.createIfcArbitraryClosedProfileDef("AREA", None, curve)) - - if len(profile_defs) == 1: - profile_def = profile_defs[0] - else: - profile_def = tmp.createIfcCompositeProfileDef("AREA", None, profile_defs) - return {"ifc_file": tmp, "profile_def": profile_def} def auto_detect_arbitrary_profile_with_voids( self, obj: bpy.types.Object, mesh: bpy.types.Mesh diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 410f9f718c..d622ac3748 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1831,6 +1831,13 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): ProfileDecorator.install(context) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") + elif tool.Geometry.is_curvelike_item(item): + tool.Model.import_curve(item, obj=obj) + obj.data.BIMMeshProperties.ifc_definition_id = item.id() + self.enable_edit_mode(context) + ProfileDecorator.install(context) + if not bpy.app.background: + tool.Blender.set_viewport_tool("bim.cad_tool") else: self.report({"INFO"}, f"Editing {item.is_a()} geometry is not supported") @@ -2022,6 +2029,50 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): product=element, representation=new_footprint, ) + elif tool.Geometry.is_curvelike_item(item): + ProfileDecorator.uninstall() + new = tool.Model.export_curves(obj) + + if not new: + + def msg(self, context): + self.layout.label(text="INVALID PROFILE") + + bpy.context.window_manager.popup_menu(msg, title="Error", icon="ERROR") + ProfileDecorator.install(bpy.context) + self.enable_edit_mode(bpy.context) + return + + additional_curves = [] + if len(new) > 1: + additional_curves = new[1:] + new = new[0] + + for inverse in tool.Ifc.get().get_inverse(item): + ifcopenshell.util.element.replace_attribute(inverse, item, new) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), item) + + obj.data.BIMMeshProperties.ifc_definition_id = new.id() + tool.Geometry.import_item(obj) + + props = bpy.context.scene.BIMGeometryProperties + for item in additional_curves: + representation = tool.Geometry.get_active_representation(props.representation_obj) + representation = ifcopenshell.util.representation.resolve_representation(representation) + representation.Items = list(representation.Items) + [item] + + name = f"Item/{item.is_a()}/{item.id()}" + mesh = bpy.data.meshes.new(name) + new_obj = bpy.data.objects.new(name, mesh) + new_obj.data.BIMMeshProperties.ifc_definition_id = item.id() + scene = bpy.context.scene + scene.collection.objects.link(new_obj) + new = props.item_objs.add() + new.obj = new_obj + new_obj.matrix_world = obj.matrix_world + tool.Geometry.import_item(new_obj) + + tool.Geometry.reload_representation(props.representation_obj) def enable_edit_mode(self, context): if tool.Blender.toggle_edit_mode(context) == {"CANCELLED"}: diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index d984c65dd6..bdac1729c6 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -551,13 +551,12 @@ class Misc: class Model: def convert_si_to_unit(cls, value): pass def convert_unit_to_si(cls, value): pass - def export_curve(cls, position, edge_indices): pass def export_points(cls, position, indices): pass def export_profile(cls, obj, position=None): pass def generate_occurrence_name(cls, element_type, ifc_class): pass def get_extrusion(cls, representation): pass def import_profile(cls, profile, obj=None, position=None): pass - def import_curve(cls, obj, position, curve): pass + def import_curve(cls, curve, obj=None, position=None): pass def import_rectangle(cls, obj, position, profile): pass def load_openings(cls, openings): pass def clear_scene_openings(cls): pass diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 9d16c26484..5b9e078a89 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -860,6 +860,15 @@ class Geometry(bonsai.core.tool.Geometry): def is_meshlike_item(cls, item: ifcopenshell.entity_instance) -> bool: return item.is_a("IfcTessellatedItem") or item.is_a("IfcManifoldSolidBrep") + @classmethod + def is_curvelike_item(cls, item: ifcopenshell.entity_instance) -> bool: + return ( + item.is_a("IfcPolyline") + or item.is_a("IfcCompositeCurve") + or item.is_a("IfcIndexedPolyCurve") + or item.is_a("IfcCircle") + ) + @classmethod def is_movable(cls, item: ifcopenshell.entity_instance) -> bool: return item.is_a("IfcSweptAreaSolid") or item.is_a("IfcConic") @@ -1522,7 +1531,7 @@ class Geometry(bonsai.core.tool.Geometry): if (is_swept_area := item.is_a("IfcSweptAreaSolid")) or item.is_a("IfcConic"): position = item.Position - # Positional is optionaly only for SweptAreaSolid. + # Positional is optional only for SweptAreaSolid. if position or not is_swept_area: unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) position = ifcopenshell.util.placement.get_axis2placement(position) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index e35d54bde1..f8f267030c 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -19,6 +19,7 @@ import bpy import json import bmesh +import shapely import collections import collections.abc import numpy as np @@ -86,7 +87,9 @@ class Model(bonsai.core.tool.Model): return data @classmethod - def export_curve(cls, position: Matrix, edge_indices: list[tuple[int, int]]) -> ifcopenshell.entity_instance: + def convert_mesh_to_curve( + cls, position: Matrix, edge_indices: list[tuple[int, int]] + ) -> ifcopenshell.entity_instance: position_i = position.inverted() ifc_file = tool.Ifc.get() if len(edge_indices) == 2: @@ -130,11 +133,24 @@ class Model(bonsai.core.tool.Model): if position is None: position = Matrix() - helper = Helper(tool.Ifc.get()) - result = helper.auto_detect_profiles(obj, obj.data, position) + result = cls.auto_detect_profiles(obj, obj.data, position) if isinstance(result, dict) and result["profile_def"]: return tool.Ifc.get().add(result["profile_def"]) + @classmethod + def export_curves( + cls, obj: bpy.types.Object, position: Optional[Matrix] = None + ) -> Union[list[ifcopenshell.entity_instance], None]: + if position is None: + position = Matrix() + + results = [] + result = cls.auto_detect_curves(obj, obj.data, position) + if isinstance(result, dict) and result["curves"]: + for curve in result["curves"]: + results.append(tool.Ifc.get().add(curve)) + return results + @classmethod def export_surface(cls, obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, None]: p1, p2, p3 = [v.co.copy() for v in obj.data.vertices[0:3]] @@ -178,10 +194,10 @@ class Model(bonsai.core.tool.Model): if tool.Ifc.get().schema != "IFC2X3": cls.points = cls.export_points(position, indices["points"]) - surface.OuterBoundary = cls.export_curve(position, indices["profile"]) + surface.OuterBoundary = cls.convert_mesh_to_curve(position, indices["profile"]) results = [] for inner_curve in indices["inner_curves"]: - results.append(cls.export_curve(position, inner_curve)) + results.append(cls.convert_mesh_to_curve(position, inner_curve)) surface.InnerBoundaries = results cls.bm.free() @@ -234,7 +250,7 @@ class Model(bonsai.core.tool.Model): ) cls.edges.append([0, 1]) else: - cls.import_curve(obj, position, axis) + cls.convert_curve_to_mesh(obj, position, axis) mesh = bpy.data.meshes.new("Axis") mesh.from_pydata(cls.vertices, cls.edges, []) @@ -272,10 +288,10 @@ class Model(bonsai.core.tool.Model): profiles = profile.Profiles if profile.is_a("IfcCompositeProfileDef") else [profile] for profile in profiles: if profile.is_a("IfcArbitraryClosedProfileDef"): - cls.import_curve(obj, position, profile.OuterCurve) + cls.convert_curve_to_mesh(obj, position, profile.OuterCurve) if profile.is_a("IfcArbitraryProfileDefWithVoids"): for inner_curve in profile.InnerCurves: - cls.import_curve(obj, position, inner_curve) + cls.convert_curve_to_mesh(obj, position, inner_curve) elif profile.is_a() == "IfcRectangleProfileDef": cls.import_rectangle(obj, position, profile) @@ -301,6 +317,48 @@ class Model(bonsai.core.tool.Model): return obj + @classmethod + def import_curve( + cls, + curve: ifcopenshell.entity_instance, + obj: Optional[bpy.types.Object] = None, + position: Optional[Matrix] = None, + ) -> bpy.types.Object: + cls.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if position is None: + position = Matrix() + + cls.vertices = [] + cls.edges = [] + cls.arcs = [] + cls.circles = [] + + if tool.Geometry.is_curvelike_item(curve): + cls.convert_curve_to_mesh(obj, position, curve) + + mesh = bpy.data.meshes.new("Curve") + mesh.from_pydata(cls.vertices, cls.edges, []) + mesh.BIMMeshProperties.subshape_type = "PROFILE" + + if obj is None: + obj = bpy.data.objects.new("Curve", mesh) + else: + old_data = obj.data + obj.data = mesh + if old_data and not old_data.users: + bpy.data.meshes.remove(old_data) + + for arc in cls.arcs: + group = obj.vertex_groups.new(name="IFCARCINDEX") + group.add(arc, 1, "REPLACE") + + for circle in cls.circles: + group = obj.vertex_groups.new(name="IFCCIRCLE") + group.add(circle, 1, "REPLACE") + + return obj + @classmethod def import_surface( cls, surface: ifcopenshell.entity_instance, obj: Optional[bpy.types.Object] = None @@ -316,9 +374,9 @@ class Model(bonsai.core.tool.Model): position = Matrix(ifcopenshell.util.placement.get_axis2placement(surface.BasisSurface.Position).tolist()) position.translation *= cls.unit_scale - cls.import_curve(obj, position, surface.OuterBoundary) + cls.convert_curve_to_mesh(obj, position, surface.OuterBoundary) for inner_boundary in surface.InnerBoundaries: - cls.import_curve(obj, position, inner_boundary) + cls.convert_curve_to_mesh(obj, position, inner_boundary) mesh = bpy.data.meshes.new("Surface") mesh.from_pydata(cls.vertices, cls.edges, []) @@ -340,7 +398,9 @@ class Model(bonsai.core.tool.Model): return obj @classmethod - def import_curve(cls, obj: bpy.types.Object, position: Matrix, curve: ifcopenshell.entity_instance) -> None: + def convert_curve_to_mesh( + cls, obj: bpy.types.Object, position: Matrix, curve: ifcopenshell.entity_instance + ) -> None: offset = len(cls.vertices) if curve.is_a("IfcPolyline"): @@ -356,7 +416,7 @@ class Model(bonsai.core.tool.Model): elif curve.is_a("IfcCompositeCurve"): # This is a first pass incomplete implementation only for simple polylines, and misses many details. for segment in curve.Segments: - cls.import_curve(obj, position, segment.ParentCurve) + cls.convert_curve_to_mesh(obj, position, segment.ParentCurve) elif curve.is_a("IfcIndexedPolyCurve"): is_arc = False is_closed = False @@ -1450,3 +1510,394 @@ class Model(bonsai.core.tool.Model): ) tool.Model.replace_object_ifc_representation(body, obj, representation) tool.Ifc.finish_edit(obj) + + @classmethod + def auto_detect_profiles( + cls, obj: bpy.types.Object, mesh: bpy.types.Mesh, position: Matrix | None = None + ) -> Union[tuple, dict]: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if position is None: + position = Matrix() + position_i = position.inverted() + + groups = {"IFCARCINDEX": [], "IFCCIRCLE": []} + for i, group in enumerate(obj.vertex_groups): + if "IFCARCINDEX" in group.name: + groups["IFCARCINDEX"].append(i) + elif "IFCCIRCLE" in group.name: + groups["IFCCIRCLE"].append(i) + + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") + + # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess + # This is how we access vertex groups via bmesh, apparently, it's not very intuitive + deform_layer = bm.verts.layers.deform.active + + # Sanity check + group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} + for vert in bm.verts: + total_groups = 0 + is_circle = False + for group_type, group_indices in groups.items(): + if not group_indices: + continue + is_special, group_index = tool.Blender.bmesh_check_vertex_in_groups(vert, deform_layer, group_indices) + if not is_special: + continue + if group_type == "IFCCIRCLE": + is_circle = True + group_verts[group_type].setdefault(group_index, 0) + group_verts[group_type][group_index] += 1 + total_groups += 0 + if total_groups > 1: # A vert can only belong to one group + return (False, "AMBIGUOUS_SPECIAL_VERTEX") + elif is_circle: + pass # Circles are allowed to be unclosed + elif total_groups == 0 and len(vert.link_edges) != 2: # Unclosed loop or forked loop + return (False, "UNCLOSED_LOOP") + + for group_type, group_counts in group_verts.items(): + if group_type == "IFCARCINDEX": + for group_count in group_counts.values(): + if group_count != 3: # Each arc needs 3 verts + return (False, "3POINT_ARC") + elif group_type == "IFCCIRCLE": + for group_count in group_counts.values(): + if group_count != 2: # Each circle needs 2 verts + return (False, "CIRCLE") + + loop_edges = set(bm.edges) + + # Create loops from edges + loops = [] + while loop_edges: + edge = loop_edges.pop() + loop = [edge] + has_found_connected_edge = True + while has_found_connected_edge: + has_found_connected_edge = False + for edge in loop_edges.copy(): + edge_verts = set(edge.verts) + if edge_verts & set(loop[0].verts): + loop.insert(0, edge) + loop_edges.remove(edge) + has_found_connected_edge = True + elif edge_verts & set(loop[-1].verts): + loop.append(edge) + loop_edges.remove(edge) + has_found_connected_edge = True + loops.append(loop) + + tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) + + def is_in_group(v, group_name): + for group_index in groups[group_name]: + if group_index in v[deform_layer]: + return True + return False + + def get_group_index(v, group_name): + for group_index in groups[group_name]: + if group_index in v[deform_layer]: + return group_index + + # Convert all loops into IFC curves + curves = [] + for loop in loops: + + if len(loop) == 1 and all([is_in_group(v, "IFCCIRCLE") for v in loop[0].verts]): + v1, v2 = loop[0].verts + mid = v1.co.lerp(v2.co, 0.5) + mid = (position_i @ (mid / unit_scale)).to_2d() + v1 = (position_i @ (v1.co / unit_scale)).to_2d() + radius = (mid - v1).length + curves.append( + tmp.createIfcCircle(tmp.createIfcAxis2Placement2D(tmp.createIfcCartesianPoint(list(mid))), radius) + ) + else: # For now, assume closed loop + loop_verts = [] + for i, edge in enumerate(loop): + if i == 0: + if edge.verts[0] in loop[i + 1].verts: + loop_verts.append(edge.verts[1]) + loop_verts.append(edge.verts[0]) + elif edge.verts[1] in loop[i + 1].verts: + loop_verts.append(edge.verts[0]) + loop_verts.append(edge.verts[1]) + else: + loop_verts.append(edge.other_vert(loop_verts[-1])) + loop_verts.pop() + + # Handle loop_verts possibly starting halfway through an arc + if (group_index := get_group_index(loop_verts[0], "IFCARCINDEX")) is not None: + if get_group_index(loop_verts[1], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + loop_verts.insert(0, loop_verts.pop()) + elif get_group_index(loop_verts[2], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + + if tmp.schema != "IFC2X3" and any([is_in_group(v, "IFCARCINDEX") for v in loop_verts]): + # We need to specify segments + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + points = tmp.createIfcCartesianPointList2D(coord_list) + i = 0 + segments = [] + total_verts = len(loop_verts) + while i < total_verts: + v = loop_verts[i] + if ( + i + 1 != total_verts + and is_in_group(v, "IFCARCINDEX") + and is_in_group(loop_verts[i + 1], "IFCARCINDEX") + ): + segments.append(tmp.createIfcArcIndex([i + 1, i + 2, i + 3])) + i += 2 + else: + segments.append(tmp.createIfcLineIndex([i + 1, i + 2])) + i += 1 + # Close the loop + last_segment_indices = list(segments[-1][0]) + last_segment_indices[-1] = 1 + segments[-1][0] = last_segment_indices + curves.append(tmp.createIfcIndexedPolyCurve(points, segments)) + elif tmp.schema == "IFC2X3": + points = [ + tmp.createIfcCartesianPoint(list((position_i @ (v.co / unit_scale)).to_2d())) + for v in loop_verts + ] + points.append(points[0]) + curves.append(tmp.createIfcPolyline(points)) + else: # Pure straight polyline, no segments required + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + coord_list.append(coord_list[0]) + points = tmp.createIfcCartesianPointList2D(coord_list) + curves.append(tmp.createIfcIndexedPolyCurve(points)) + + # Sort IFC curves into either closed, or closed with void profile defs + profile_defs = [] + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + + # First convert to Shapely + polygons = {} + for curve in curves: + geometry = ifcopenshell.geom.create_shape(settings, curve) + v = ifcopenshell.util.shape.get_vertices(geometry, is_2d=True) + v = np.round(v, 4) # Round to nearest 0.1mm, otherwise things like circles don't polygonise reliably + edges = ifcopenshell.util.shape.get_edges(geometry) + boundary_lines = [shapely.LineString([v[e[0]], v[e[1]]]) for e in edges] + unioned_boundaries = shapely.union_all(shapely.GeometryCollection(boundary_lines)) + closed_polygons = shapely.polygonize(unioned_boundaries.geoms) + for polygon in closed_polygons.geoms: + polygons[curve] = polygon + break + + # Check for contains properly (IFC doesn't allow common boundary points) + outer_inner = {} + inner_outer = {} + for curve, polygon in polygons.items(): + for curve2, polygon2 in polygons.items(): + if curve == curve2: + continue + if polygon.contains_properly(polygon2): + outer_inner.setdefault(curve, []).append(curve2) + inner_outer.setdefault(curve2, []).append(curve) + + # Odd-even rule for nested curves + nested_level = {c: len(inner_outer[c]) if c in inner_outer else 0 for c in curves} + for curve in sorted(curves, key=lambda c: nested_level[c]): + level = nested_level[curve] + if level % 2 == 0: + if curve in outer_inner: + inners = [c for c in outer_inner[curve] if nested_level[c] == level + 1] + profile_defs.append(tmp.createIfcArbitraryProfileDefWithVoids("AREA", None, curve, inners)) + else: + profile_defs.append(tmp.createIfcArbitraryClosedProfileDef("AREA", None, curve)) + + if len(profile_defs) == 1: + profile_def = profile_defs[0] + else: + profile_def = tmp.createIfcCompositeProfileDef("AREA", None, profile_defs) + return {"ifc_file": tmp, "profile_def": profile_def} + + + @classmethod + def auto_detect_curves( + cls, obj: bpy.types.Object, mesh: bpy.types.Mesh, position: Matrix | None = None + ) -> Union[tuple, dict]: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + + if position is None: + position = Matrix() + position_i = position.inverted() + + groups = {"IFCARCINDEX": [], "IFCCIRCLE": []} + for i, group in enumerate(obj.vertex_groups): + if "IFCARCINDEX" in group.name: + groups["IFCARCINDEX"].append(i) + elif "IFCCIRCLE" in group.name: + groups["IFCCIRCLE"].append(i) + + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.delete(bm, geom=bm.faces, context="FACES_ONLY") + + # https://docs.blender.org/api/blender_python_api_2_63_8/bmesh.html#CustomDataAccess + # This is how we access vertex groups via bmesh, apparently, it's not very intuitive + deform_layer = bm.verts.layers.deform.active + + # Sanity check + group_verts = {"IFCARCINDEX": {}, "IFCCIRCLE": {}} + for vert in bm.verts: + total_groups = 0 + is_circle = False + for group_type, group_indices in groups.items(): + if not group_indices: + continue + is_special, group_index = tool.Blender.bmesh_check_vertex_in_groups(vert, deform_layer, group_indices) + if not is_special: + continue + if group_type == "IFCCIRCLE": + is_circle = True + group_verts[group_type].setdefault(group_index, 0) + group_verts[group_type][group_index] += 1 + total_groups += 0 + if total_groups > 1: # A vert can only belong to one group + return (False, "AMBIGUOUS_SPECIAL_VERTEX") + elif is_circle: + pass # Circles are allowed to be unclosed + elif total_groups == 0 and len(vert.link_edges) > 2: # Forked loop + return (False, "FORKED_LOOP") + + for group_type, group_counts in group_verts.items(): + if group_type == "IFCARCINDEX": + for group_count in group_counts.values(): + if group_count != 3: # Each arc needs 3 verts + return (False, "3POINT_ARC") + elif group_type == "IFCCIRCLE": + for group_count in group_counts.values(): + if group_count != 2: # Each circle needs 2 verts + return (False, "CIRCLE") + + loop_edges = set(bm.edges) + + # Create loops from edges + loops = [] + while loop_edges: + edge = loop_edges.pop() + loop = [edge] + has_found_connected_edge = True + while has_found_connected_edge: + has_found_connected_edge = False + for edge in loop_edges.copy(): + edge_verts = set(edge.verts) + if edge_verts & set(loop[0].verts): + loop.insert(0, edge) + loop_edges.remove(edge) + has_found_connected_edge = True + elif edge_verts & set(loop[-1].verts): + loop.append(edge) + loop_edges.remove(edge) + has_found_connected_edge = True + loops.append(loop) + print('autodetected loops', loops) + + tmp = ifcopenshell.file(schema=tool.Ifc.get().schema) + + def is_in_group(v, group_name): + for group_index in groups[group_name]: + if group_index in v[deform_layer]: + return True + return False + + def get_group_index(v, group_name): + for group_index in groups[group_name]: + if group_index in v[deform_layer]: + return group_index + + # Convert all loops into IFC curves + curves = [] + for loop in loops: + + if len(loop) == 1 and all([is_in_group(v, "IFCCIRCLE") for v in loop[0].verts]): + v1, v2 = loop[0].verts + mid = v1.co.lerp(v2.co, 0.5) + mid = (position_i @ (mid / unit_scale)).to_2d() + v1 = (position_i @ (v1.co / unit_scale)).to_2d() + radius = (mid - v1).length + curves.append( + tmp.createIfcCircle(tmp.createIfcAxis2Placement2D(tmp.createIfcCartesianPoint(list(mid))), radius) + ) + else: + loop_verts = [] + for i, edge in enumerate(loop): + if i == 0 and len(loop) == 1: + loop_verts.append(edge.verts[0]) + loop_verts.append(edge.verts[1]) + elif i == 0: + if edge.verts[0] in loop[i + 1].verts: + loop_verts.append(edge.verts[1]) + loop_verts.append(edge.verts[0]) + elif edge.verts[1] in loop[i + 1].verts: + loop_verts.append(edge.verts[0]) + loop_verts.append(edge.verts[1]) + else: + loop_verts.append(edge.other_vert(loop_verts[-1])) + + if is_closed := loop_verts[0] == loop_verts[-1]: + loop_verts.pop() + + # Handle loop_verts possibly starting halfway through an arc + if (group_index := get_group_index(loop_verts[0], "IFCARCINDEX")) is not None: + if get_group_index(loop_verts[1], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + loop_verts.insert(0, loop_verts.pop()) + elif get_group_index(loop_verts[2], "IFCARCINDEX") != group_index: + loop_verts.insert(0, loop_verts.pop()) + + if tmp.schema != "IFC2X3" and any([is_in_group(v, "IFCARCINDEX") for v in loop_verts]): + # We need to specify segments + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + points = tmp.createIfcCartesianPointList2D(coord_list) + i = 0 + segments = [] + total_verts = len(loop_verts) + while i < total_verts: + v = loop_verts[i] + if ( + i + 1 != total_verts + and is_in_group(v, "IFCARCINDEX") + and is_in_group(loop_verts[i + 1], "IFCARCINDEX") + ): + segments.append(tmp.createIfcArcIndex([i + 1, i + 2, i + 3])) + i += 2 + else: + segments.append(tmp.createIfcLineIndex([i + 1, i + 2])) + i += 1 + if is_closed: + # Close the loop + last_segment_indices = list(segments[-1][0]) + last_segment_indices[-1] = 1 + segments[-1][0] = last_segment_indices + curves.append(tmp.createIfcIndexedPolyCurve(points, segments)) + elif tmp.schema == "IFC2X3": + points = [ + tmp.createIfcCartesianPoint(list((position_i @ (v.co / unit_scale)).to_2d())) + for v in loop_verts + ] + if is_closed: + points.append(points[0]) + curves.append(tmp.createIfcPolyline(points)) + else: # Pure straight polyline, no segments required + coord_list = [list((position_i @ (v.co / unit_scale)).to_2d()) for v in loop_verts] + if is_closed: + coord_list.append(coord_list[0]) + points = tmp.createIfcCartesianPointList2D(coord_list) + curves.append(tmp.createIfcIndexedPolyCurve(points)) + + return {"ifc_file": tmp, "curves": curves}