From cdb594b5c2b3578e43a353335a73cdbe32dddc3f Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 2 Jul 2026 16:26:34 -0500 Subject: [PATCH] Bonsai: fix Explore tool highlight and append placement for linked models The queried-element highlight broke in two ways: layerset-sliced linked meshes contain ngons, so highlight triangles are now built from calc_loop_triangles instead of polygon vertices; and ID properties read back as IDPropertyArrays which GPUIndexBuf rejects, so selection geometry is converted to plain tuples. TRIS drawing is also gated on its own data instead of piggybacking on the edges check. Moved links now highlight at their displayed location: the ray-cast instance matrix is passed through to select_linked_element, and find_obj_root compares it against the empty and object matrices combined (instanced occurrence objects have non-identity local matrices), falling back to the collection's only instance when no matrix is available (e.g. select by GUID). bim.append_inspected_linked_element also places the appended element where the moved link is displayed, using the new tool.Project.calculate_link_delta_matrix helper. Co-Authored-By: Claude Fable 5 --- .../bonsai/bim/module/project/decorator.py | 1 + .../bonsai/bim/module/project/operator.py | 15 +++- src/bonsai/bonsai/tool/project.py | 79 ++++++++++++++----- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/project/decorator.py b/src/bonsai/bonsai/bim/module/project/decorator.py index 7af79d3add..6087805979 100644 --- a/src/bonsai/bonsai/bim/module/project/decorator.py +++ b/src/bonsai/bonsai/bim/module/project/decorator.py @@ -99,6 +99,7 @@ class ProjectDecorator: if geom.selected_edges: self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges) + if geom.selected_tris: self.draw_batch( "TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris ) diff --git a/src/bonsai/bonsai/bim/module/project/operator.py b/src/bonsai/bonsai/bim/module/project/operator.py index 4bde9c15bb..c1dc3c462e 100644 --- a/src/bonsai/bonsai/bim/module/project/operator.py +++ b/src/bonsai/bonsai/bim/module/project/operator.py @@ -2661,7 +2661,7 @@ class QueryLinkedElement(bpy.types.Operator): guid = tool.Project.Link.get_guid_by_face_index(obj, face_index) assert guid is not None - tool.Project.Link.select_linked_element(context, obj, guid) + tool.Project.Link.select_linked_element(context, obj, guid, instance_matrix) self.report({"INFO"}, f"Loaded data for {guid}") ProjectDecorator.install(bpy.context) @@ -2786,6 +2786,19 @@ class AppendInspectedLinkedElement(AppendLibraryElement): if element_type and tool.Ifc.get_object(element_type) is None: self.import_type_from_ifc(element_type, context) + # If the link was moved, place the appended element where the link + # is displayed rather than at its original coordinates. + obj = tool.Ifc.get_object(element) + if isinstance(obj, bpy.types.Object): + linked_filepath = Path(queried_obj["ifc_filepath"]) + for link in props.links: + if Path(tool.Ifc.resolve_uri(link.filepath)) != linked_filepath: + continue + delta = tool.Project.calculate_link_delta_matrix(link) + if not delta.is_identity: + obj.matrix_world = delta @ obj.matrix_world + break + return {"FINISHED"} diff --git a/src/bonsai/bonsai/tool/project.py b/src/bonsai/bonsai/tool/project.py index c3990fa5dc..7f9ea5b196 100644 --- a/src/bonsai/bonsai/tool/project.py +++ b/src/bonsai/bonsai/tool/project.py @@ -117,6 +117,32 @@ class Project(bonsai.core.tool.Project): local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] return Matrix(np.linalg.inv(local_matrix) @ global_matrix) + @classmethod + def calculate_link_delta_matrix(cls, link: Link) -> Matrix: + """Get the matrix mapping the link's unmoved world positions to its moved ones. + + Returns identity when the link has no saved transformation. + """ + if tool.Ifc.get(): + transformation = tool.Ifc.get().by_id(link.ifc_definition_id)[1] # Identification + else: + transformation = link.transformation + + if not transformation: + return Matrix.Identity(4) + transformation = np.fromstring(transformation, sep=",", dtype=np.float64).reshape(4, 4) + if np.allclose(transformation, np.eye(4)): + return Matrix.Identity(4) + + gprops = tool.Georeference.get_georeference_props() + rot = ifcopenshell.util.shape_builder.np_rotation_matrix(radians(-float(gprops.model_project_north)), 4, "Z") + local_matrix = rot @ np.eye(4) + local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] + + # Link empty matrix is inv(local) @ transformation @ global (see + # calculate_link_matrix), so moved = inv(local) @ T @ local @ unmoved. + return Matrix(np.linalg.inv(local_matrix) @ transformation @ local_matrix) + @classmethod def save_link_transformation(cls, link: Link) -> None: """Persist the link handle's current world matrix as the link's saved transformation.""" @@ -898,9 +924,16 @@ class Project(bonsai.core.tool.Project): selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map] for polygon in guid_polygons: - selected_tris.append(tuple(vert_map[vi] for vi in polygon.vertices)) selected_edges.extend(tuple([vert_map[vi] for vi in e]) for e in polygon.edge_keys) + # Polygons are not necessarily triangles (e.g. layerset-sliced + # meshes contain ngons), so triangles come from the loop triangles. + mesh.calc_loop_triangles() + polygon_range = range(*slice_.indices(len(mesh.polygons))) + for tri in mesh.loop_triangles: + if tri.polygon_index in polygon_range: + selected_tris.append(tuple(vert_map[vi] for vi in tri.vertices)) + obj["selected_vertices"] = selected_vertices obj["selected_edges"] = selected_edges obj["selected_tris"] = selected_tris @@ -937,11 +970,9 @@ class Project(bonsai.core.tool.Project): from bonsai.bim.module.project.data import LinksData from bonsai.bim.module.project.decorator import ProjectDecorator - # Not sure if there's a difference between `instance_matrix` coming from `ray_cast` - # and usual `matrix_world`, maybe we can just get it from object always. - if instance_matrix is None: - instance_matrix = obj.matrix_world - + # `instance_matrix` is the world matrix of the hit collection instance + # from `ray_cast` (link empty matrix included). Without it, the root + # empty is resolved as the collection's only instance. cls.deselect_queried_linked_element() cls.set_queried_linked_element(obj, guid, instance_matrix) cls.select_linked_element_geom(obj, guid) @@ -998,7 +1029,7 @@ class Project(bonsai.core.tool.Project): ProjectDecorator.install(context) @classmethod - def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix) -> None: + def set_queried_linked_element(cls, obj: bpy.types.Object, guid: str, instance_matrix: Matrix | None) -> None: props = tool.Project.get_project_props() props.queried_obj = obj props.queried_obj_root = cls.find_obj_root(obj, instance_matrix) @@ -1017,17 +1048,22 @@ class Project(bonsai.core.tool.Project): del obj[field] @classmethod - def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix) -> bpy.types.Object | None: + def find_obj_root(cls, obj: bpy.types.Object, matrix: Matrix | None) -> bpy.types.Object | None: collections = set(obj.users_collection) - for o in bpy.data.objects: - if ( - o.type != "EMPTY" - or o.instance_type != "COLLECTION" - or o.instance_collection not in collections - or not np.allclose(matrix, o.matrix_world, atol=1e-4) - ): - continue - return o + candidates = [ + o + for o in bpy.data.objects + if o.type == "EMPTY" and o.instance_type == "COLLECTION" and o.instance_collection in collections + ] + if matrix is not None: + # `matrix` is the instance's world matrix - the instancing + # empty's matrix combined with the object's own local matrix + # (non-identity for instanced occurrence objects). + for o in candidates: + if np.allclose(matrix, np.array(o.matrix_world) @ np.array(obj.matrix_world), atol=1e-4): + return o + if len(candidates) == 1: + return candidates[0] class SelectedGeometry(NamedTuple): selected_vertices: list[tuple[float, float, float]] @@ -1036,8 +1072,11 @@ class Project(bonsai.core.tool.Project): @classmethod def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry: + # ID properties are returned as IDPropertyArrays (the whole + # property when empty, the items otherwise), which the GPU module + # rejects as batch indices - convert to plain tuples. return cls.SelectedGeometry( - obj["selected_vertices"], - obj["selected_edges"], - obj["selected_tris"], + [tuple(v) for v in obj["selected_vertices"]], + [tuple(e) for e in obj["selected_edges"]], + [tuple(t) for t in obj["selected_tris"]], )