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 <noreply@anthropic.com>
This commit is contained in:
Petru Conduraru
2026-07-12 19:06:43 +03:00
parent 980988f208
commit 2cee95e2d4
@@ -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