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:
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
)
@@ -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"}
+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(",")]
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"]],
)