From 2cee95e2d4fce6517a6fc605074dadf9a80356d9 Mon Sep 17 00:00:00 2001 From: Petru Conduraru Date: Sun, 12 Jul 2026 19:06:43 +0300 Subject: [PATCH] Bonsai: fix SHAPELY fill mode for linked projects in drawings (#7061) Printing a drawing with SHAPELY "Fill Mode" produced no .surface fill paths for any element from a Linked Project. Two compounding bugs in CreateDrawing.generate_linework's fill pass: - Object resolution: it resolved Blender objects with tool.Ifc.get_object, which only maps entities of the active session file. Linked-model elements live in a separate ifcopenshell.file and are merged into GlobalId-keyed "chunk" objects, so get_object returned None and raycast_objs never included any linked geometry. - Matrix: linked geometry sits inside a collection instanced by an empty; Blender does not fold that instancing transform into the children's matrix_world, so raycasting with the raw matrix always missed (the ray is in host/world space, the geometry at the linked file's untransformed local coordinates). The reverse resolution of a raycast hit to an element via get_entity likewise only worked for the main file. Track the link per file; for linked elements resolve the object via tool.Project.Link.get_obj_by_guid and raycast against the effective matrix link_matrix @ obj.matrix_world (raycast_objs is now obj -> effective matrix); resolve a hit on a linked chunk via get_guid_by_face_index + get_element_by_guid. The main-file path is guarded by link is None / is_linked_element and is unchanged. Verified live in headless Blender with a host file linking three georeferenced buildings (distinct IfcMapConversion transforms): SHAPELY fill on the plan drawing produced 0 .surface paths before and 7 after, distributed across all three buildings' positions. Ordinary non-SHAPELY linked linework is unaffected. Generated with the assistance of an AI coding tool. Co-Authored-By: Claude Opus 4.8 --- .../bonsai/bim/module/drawing/operator.py | 62 +++++++++++++++---- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/bonsai/bonsai/bim/module/drawing/operator.py b/src/bonsai/bonsai/bim/module/drawing/operator.py index 7c53067a00..300ea7474a 100644 --- a/src/bonsai/bonsai/bim/module/drawing/operator.py +++ b/src/bonsai/bonsai/bim/module/drawing/operator.py @@ -934,8 +934,10 @@ class CreateDrawing(bpy.types.Operator): bim_props = tool.Blender.get_bim_props() prefs = tool.Blender.get_addon_preferences() - # Map ifc_path → (ifc_file, link_matrix); main file has no link_matrix (None) - files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]] = {bim_props.ifc_file: (tool.Ifc.get(), None)} + # Map ifc_path → (ifc_file, link_matrix, link); main file has no link_matrix/link (None) + files: dict[str, tuple[ifcopenshell.file, Optional[Matrix], Optional["Link"]]] = { + bim_props.ifc_file: (tool.Ifc.get(), None, None) + } props = tool.Project.get_project_props() for link in props.get_loaded_links_for_drawings(): @@ -943,7 +945,7 @@ class CreateDrawing(bpy.types.Operator): link_matrix = tool.Project.calculate_link_matrix(link) except Exception: link_matrix = None - files[link.filepath] = (self.get_linked_file(link), link_matrix) + files[link.filepath] = (self.get_linked_file(link), link_matrix, link) target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"] self.setup_serialiser(target_view) @@ -954,10 +956,21 @@ class CreateDrawing(bpy.types.Operator): # Accumulated across every file in the loop below (main model plus any # linked models) so the SHAPELY fill pass after the loop covers all of # them, not just whichever file happened to be processed last. - raycast_objs = set() + # + # Maps each raycastable object to its *effective* world matrix. Objects + # from a linked model live inside a collection instanced by an empty + # (see tool.Project.calculate_link_matrix / LoadLink.link_blend), and + # Blender does not propagate that instancing transform onto the + # children's own .matrix_world. Raycasting linked objects with their + # raw .matrix_world therefore always misses (the ray, which is in the + # correctly-positioned host/world space, never meets geometry still + # sitting at the linked file's own un-transformed local coordinates), + # so surface fills silently never render for linked models. Combining + # the link matrix with the object's own matrix here fixes that. + raycast_objs: dict[bpy.types.Object, Matrix] = {} elements_with_faces = set() - for ifc_path, (ifc, link_matrix) in files.items(): + for ifc_path, (ifc, link_matrix, link) in files.items(): # Don't use draw.main() just whilst we're prototyping and experimenting # TODO: hash paths are never used ifc_hash = hashlib.md5(ifc_path.encode("utf-8")).hexdigest() @@ -970,10 +983,21 @@ class CreateDrawing(bpy.types.Operator): for element in drawing_elements.copy(): if element.is_a("IfcAnnotation"): continue - obj = tool.Ifc.get_object(element) + # tool.Ifc.get_object() only resolves objects that belong to the + # active session file (tool.Ifc.get()). Elements from a linked + # model live in their own standalone ifcopenshell.file (see + # get_linked_file() above) and are merged into "chunk" objects + # keyed by GlobalId (see tool.Project.Link), so they need to be + # looked up through that separate mechanism instead. + obj = ( + tool.Ifc.get_object(element) + if link is None + else tool.Project.Link.get_obj_by_guid(link, element.GlobalId) + ) if obj and obj.type == "MESH" and len(obj.data.polygons): elements_with_faces.add(element.GlobalId) - raycast_objs.add(obj) + effective_matrix = obj.matrix_world if link_matrix is None else link_matrix @ obj.matrix_world + raycast_objs[obj] = effective_matrix # Get all representation contexts to see what we're dealing with. # Drawings only draw bodies and annotations (and facetation, due to a Revit bug). @@ -1087,7 +1111,17 @@ class CreateDrawing(bpy.types.Operator): raycast_results = self.cast_rays_and_get_best_object(raycast_objs, centroid3d, camera_dir) raycast_element = None if raycast_obj := raycast_results[0]: - raycast_element = tool.Ifc.get_entity(raycast_obj) + if tool.Project.Link.is_linked_element(raycast_obj): + # A linked model's geometry is merged into "chunk" + # objects covering multiple elements (see + # tool.Project.Link), so the hit face needs to be + # resolved to a GlobalId first. + face_index = raycast_results[2] + guid = tool.Project.Link.get_guid_by_face_index(raycast_obj, face_index) + if guid: + raycast_element = self.get_element_by_guid(guid) + else: + raycast_element = tool.Ifc.get_entity(raycast_obj) if raycast_element: path = etree.Element("path") @@ -1632,7 +1666,7 @@ class CreateDrawing(bpy.types.Operator): return tuple(map(float, arr)) def cast_rays_and_get_best_object( - self, objs_to_raycast: list[bpy.types.Object], ray_origin, ray_direction + self, objs_to_raycast: dict[bpy.types.Object, Matrix], ray_origin, ray_direction ) -> Union[tuple[bpy.types.Object, Vector, int], tuple[None, None, None]]: # This could be optimised even further with 2D box culling best_length_squared = 1.0 @@ -1640,8 +1674,12 @@ class CreateDrawing(bpy.types.Operator): best_hit = None best_face_index = None - for obj in objs_to_raycast: - matrix_inv = obj.matrix_world.inverted() + for obj, matrix_world in objs_to_raycast.items(): + # NB: matrix_world here is the object's *effective* world matrix + # (see the raycast_objs comment in generate_linework), which for + # objects belonging to a linked model already has the link's + # transform folded in. It's deliberately not obj.matrix_world. + matrix_inv = matrix_world.inverted() ray_origin_obj = matrix_inv @ ray_origin ray_direction_obj = ray_direction.to_4d() ray_direction_obj[3] = 0.0 @@ -1650,7 +1688,7 @@ class CreateDrawing(bpy.types.Operator): success, location, normal, face_index = obj.ray_cast(ray_origin_obj, ray_direction_obj) if success: - hit = obj.matrix_world @ location + hit = matrix_world @ location length_squared = (hit - ray_origin).length_squared if best_obj is None or length_squared < best_length_squared: best_length_squared = length_squared