From 7864657f88b0bbb6604807ee3b752a9a267d0fdc Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Sun, 30 Nov 2025 15:53:42 -0600 Subject: [PATCH] Fix #6334: Preserve annotation vertex order during subdivision to prevent arrow/text flip --- .../bonsai/bim/module/geometry/operator.py | 44 ++++++++ src/bonsai/bonsai/tool/geometry.py | 102 ++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 903473b1b6..ca54376c5f 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -2409,6 +2409,14 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): props = tool.Geometry.get_geometry_props() item = tool.Geometry.get_active_representation(obj) assert item + + # Fix vertex order for annotation items + rep_obj = props.representation_obj + if rep_obj: + element = tool.Ifc.get_entity(rep_obj) + if element and self._is_annotation_object(element): + tool.Geometry.ensure_annotation_vertex_order(obj) + if tool.Geometry.is_meshlike_item(item): if tool.Geometry.is_geometric_data(obj.data) and ( item.is_a("IfcVertex") or item.is_a("IfcEdge") or obj.data.polygons @@ -2548,6 +2556,31 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): props.mode = "EDIT" props.is_changing_mode = False + def _is_annotation_object(self, element: ifcopenshell.entity_instance) -> bool: + """Check if element is an annotation object that needs vertex order preservation.""" + if not element.is_a("IfcAnnotation"): + return False + + # Object types that have semantic vertex order (arrow direction, text position) + annotation_types_with_order = { + "TEXT_LEADER", + "DIMENSION", + "RADIUS", + "DIAMETER", + "ANGLE", + "FALL", + "SLOPE_ANGLE", + "SLOPE_FRACTION", + "SLOPE_PERCENT", + "STAIR_ARROW", + "PLAN_LEVEL", + "SECTION_LEVEL", + "SECTION", + "ELEVATION", + } + + return element.ObjectType in annotation_types_with_order + class DirectProfileEdit(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.direct_profile_edit" @@ -3374,6 +3407,17 @@ class ImportRepresentationItems(bpy.types.Operator, tool.Ifc.Operator): tool.Root.reload_item_decorator() + element = tool.Ifc.get_entity(obj) + if element and element.is_a("IfcAnnotation") and element.ObjectType in { + "TEXT_LEADER", "DIMENSION", "RADIUS", "DIAMETER", "ANGLE", + "FALL", "SLOPE_ANGLE", "SLOPE_FRACTION", "SLOPE_PERCENT", + "STAIR_ARROW", "PLAN_LEVEL", "SECTION_LEVEL", "SECTION", "ELEVATION" + }: + for item_obj_data in props.item_objs: + item_obj = item_obj_data.obj + if item_obj and isinstance(item_obj.data, bpy.types.Mesh) and item_obj.data.vertices: + item_obj.data["bonsai_first_vert_co"] = item_obj.data.vertices[0].co[:] + class UpdateItemAttributes(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.update_item_attributes" diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 27eed8c9fd..5d77b6c323 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -1904,6 +1904,17 @@ class Geometry(bonsai.core.tool.Geometry): obj.data.transform(transformation_i) cls.record_object_position(obj) + # ADD THIS AT THE END - Store initial vertex order for annotations + if rep_obj and (element := tool.Ifc.get_entity(rep_obj)): + if element.is_a("IfcAnnotation") and element.ObjectType in { + "TEXT_LEADER", "DIMENSION", "RADIUS", "DIAMETER", "ANGLE", + "FALL", "SLOPE_ANGLE", "SLOPE_FRACTION", "SLOPE_PERCENT", + "STAIR_ARROW", "PLAN_LEVEL", "SECTION_LEVEL", "SECTION", "ELEVATION" + }: + # Store the initial first vertex position + if isinstance(obj.data, bpy.types.Mesh) and obj.data.vertices: + obj.data["bonsai_first_vert_co"] = obj.data.vertices[0].co[:] + @classmethod def disable_item_mode(cls) -> None: props = tool.Geometry.get_geometry_props() @@ -2340,3 +2351,94 @@ class Geometry(bonsai.core.tool.Geometry): continue objects.add(obj) return objects + + @classmethod + def ensure_annotation_vertex_order(cls, obj: bpy.types.Object) -> None: + """ + Ensure vertices form a continuous path from start to end. + Uses the original first vertex position as a reference point. + """ + mesh = obj.data + if not isinstance(mesh, bpy.types.Mesh): + return + + # Get the original first vertex position from custom properties + if "bonsai_first_vert_co" in mesh: + original_first_co = Vector(mesh["bonsai_first_vert_co"]) + else: + # Store it for next time + if mesh.vertices: + original_first_co = Vector(mesh.vertices[0].co) + mesh["bonsai_first_vert_co"] = original_first_co[:] + else: + return + + bm = bmesh.new() + bm.from_mesh(mesh) + bm.verts.ensure_lookup_table() + bm.edges.ensure_lookup_table() + + if len(bm.verts) == 0: + bm.free() + return + + # Find endpoints (vertices with only one connected edge) + endpoints = [v for v in bm.verts if len(v.link_edges) == 1] + + # Choose the endpoint closest to the original first vertex position + if len(endpoints) == 0: + # Closed loop - pick any vertex as start + start_vert = bm.verts[0] + elif len(endpoints) == 1: + # Single endpoint + start_vert = endpoints[0] + else: + # Choose endpoint closest to where the original first vertex was + start_vert = min(endpoints, key=lambda v: (v.co - original_first_co).length) + + # Build ordered vertex list by following edges + ordered_verts = [start_vert] + current_vert = start_vert + visited_edges = set() + + while True: + # Find next unvisited edge + next_edge = None + for edge in current_vert.link_edges: + if edge not in visited_edges: + next_edge = edge + break + + if not next_edge: + break + + visited_edges.add(next_edge) + next_vert = next_edge.other_vert(current_vert) + + # Avoid going back on ourselves + if next_vert not in ordered_verts: + ordered_verts.append(next_vert) + + current_vert = next_vert + + # Store vertex coordinates in the correct order + new_verts_co = [v.co.copy() for v in ordered_verts] + + # Update the stored first vertex position to the new first vertex + mesh["bonsai_first_vert_co"] = new_verts_co[0][:] + + # Clear and rebuild mesh with correct vertex order + bm.clear() + + # Create new vertices in order + new_verts = [bm.verts.new(co) for co in new_verts_co] + bm.verts.ensure_lookup_table() + + # Create edges connecting consecutive vertices + for i in range(len(new_verts) - 1): + bm.edges.new([new_verts[i], new_verts[i + 1]]) + + # Write back to mesh + bm.to_mesh(mesh) + bm.free() + mesh.update() \ No newline at end of file