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 <noreply@anthropic.com>
This commit is contained in:
Ryan Schultz
2026-07-02 16:26:34 -05:00
parent 028e593939
commit cdb594b5c2
3 changed files with 74 additions and 21 deletions
@@ -99,6 +99,7 @@ class ProjectDecorator:
if geom.selected_edges: if geom.selected_edges:
self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges) self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges)
if geom.selected_tris:
self.draw_batch( self.draw_batch(
"TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris "TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris
) )
@@ -2661,7 +2661,7 @@ class QueryLinkedElement(bpy.types.Operator):
guid = tool.Project.Link.get_guid_by_face_index(obj, face_index) guid = tool.Project.Link.get_guid_by_face_index(obj, face_index)
assert guid is not None 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}") self.report({"INFO"}, f"Loaded data for {guid}")
ProjectDecorator.install(bpy.context) ProjectDecorator.install(bpy.context)
@@ -2786,6 +2786,19 @@ class AppendInspectedLinkedElement(AppendLibraryElement):
if element_type and tool.Ifc.get_object(element_type) is None: if element_type and tool.Ifc.get_object(element_type) is None:
self.import_type_from_ifc(element_type, context) 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"} return {"FINISHED"}
+59 -20
View File
@@ -117,6 +117,32 @@ class Project(bonsai.core.tool.Project):
local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")] local_matrix[:, 3][:3] = [float(o) for o in gprops.model_origin_si.split(",")]
return Matrix(np.linalg.inv(local_matrix) @ global_matrix) 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 @classmethod
def save_link_transformation(cls, link: Link) -> None: def save_link_transformation(cls, link: Link) -> None:
"""Persist the link handle's current world matrix as the link's saved transformation.""" """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] selected_vertices = [obj.matrix_world @ mesh.vertices[vi].co for vi in vert_map]
for polygon in guid_polygons: 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) 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_vertices"] = selected_vertices
obj["selected_edges"] = selected_edges obj["selected_edges"] = selected_edges
obj["selected_tris"] = selected_tris 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.data import LinksData
from bonsai.bim.module.project.decorator import ProjectDecorator from bonsai.bim.module.project.decorator import ProjectDecorator
# Not sure if there's a difference between `instance_matrix` coming from `ray_cast` # `instance_matrix` is the world matrix of the hit collection instance
# and usual `matrix_world`, maybe we can just get it from object always. # from `ray_cast` (link empty matrix included). Without it, the root
if instance_matrix is None: # empty is resolved as the collection's only instance.
instance_matrix = obj.matrix_world
cls.deselect_queried_linked_element() cls.deselect_queried_linked_element()
cls.set_queried_linked_element(obj, guid, instance_matrix) cls.set_queried_linked_element(obj, guid, instance_matrix)
cls.select_linked_element_geom(obj, guid) cls.select_linked_element_geom(obj, guid)
@@ -998,7 +1029,7 @@ class Project(bonsai.core.tool.Project):
ProjectDecorator.install(context) ProjectDecorator.install(context)
@classmethod @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 = tool.Project.get_project_props()
props.queried_obj = obj props.queried_obj = obj
props.queried_obj_root = cls.find_obj_root(obj, instance_matrix) props.queried_obj_root = cls.find_obj_root(obj, instance_matrix)
@@ -1017,17 +1048,22 @@ class Project(bonsai.core.tool.Project):
del obj[field] del obj[field]
@classmethod @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) collections = set(obj.users_collection)
for o in bpy.data.objects: candidates = [
if ( o
o.type != "EMPTY" for o in bpy.data.objects
or o.instance_type != "COLLECTION" if o.type == "EMPTY" and o.instance_type == "COLLECTION" and o.instance_collection in collections
or o.instance_collection not in collections ]
or not np.allclose(matrix, o.matrix_world, atol=1e-4) if matrix is not None:
): # `matrix` is the instance's world matrix - the instancing
continue # empty's matrix combined with the object's own local matrix
return o # (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): class SelectedGeometry(NamedTuple):
selected_vertices: list[tuple[float, float, float]] selected_vertices: list[tuple[float, float, float]]
@@ -1036,8 +1072,11 @@ class Project(bonsai.core.tool.Project):
@classmethod @classmethod
def get_selected_geometry(cls, obj: bpy.types.Object) -> SelectedGeometry: 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( return cls.SelectedGeometry(
obj["selected_vertices"], [tuple(v) for v in obj["selected_vertices"]],
obj["selected_edges"], [tuple(e) for e in obj["selected_edges"]],
obj["selected_tris"], [tuple(t) for t in obj["selected_tris"]],
) )