Bonsai: don't let one bad element abort an entire drawing print

PR #8724 fixed the specific root cause behind #8049 (a degenerate
cross-product crash in the C++ SVG serializer). Follow-up investigation
with the maintainer found that specific code path isn't reachable through
Bonsai's normal interactive camera pipeline, so the precise trigger for
the original field report is still not fully pinned down (most likely a
GUID collision in the reporter's own ad-hoc merged file). Since we can't
guarantee every possible way a single element's geometry can make the
native serializer throw, this adds defense in depth: one bad element
should not abort the entire print job.

serialize_contexts_elements now catches a per-element write failure and
continues rather than propagating it. That alone isn't sufficient though:
a thrown native exception leaves the shared SVG serializer/buffer
corrupted, silently discarding every element written before the failure
too, not just the failing one, confirmed live with fault injection. So
drawing generation is now a bounded retry loop: any pass that hits a
failure is discarded and redone from scratch on a fresh serialiser with
the failing element(s) excluded, until a clean pass is produced or 8
attempts run out. A single consolidated warning lists every skipped
element (type, GlobalId, error) at the end.

Verified live in headless Blender: a genuine native failure on one
element no longer aborts the drawing, and the other elements' geometry is
present in the output. Confirmed a healthy drawing with no problematic
elements produces unchanged output.

Found but did not fix, flagging for a separate decision: Bonsai's default
cut_mode is BISECT, which uses a completely separate per-object loop
(generate_bisect_linework) with no equivalent protection today. Same
vulnerability class, different code path, out of scope here since the
report specifically concerns the OpenCASCADE path.

Related to #8049

Generated with the assistance of an AI coding tool.
This commit is contained in:
Petru Conduraru
2026-07-18 18:23:40 +03:00
parent 89523999b3
commit 6003c3b4cc
@@ -719,7 +719,11 @@ class CreateDrawing(bpy.types.Operator):
processed = set()
for elem in it:
processed.add(ifc.by_id(elem.id))
self.serialiser.write(elem)
try:
self.serialiser.write(elem)
except Exception as e:
self.failed_linework_elements.append((elem.type, elem.guid, str(e)))
continue
tree.add_element(elem)
drawing_elements -= processed
@@ -1024,7 +1028,6 @@ class CreateDrawing(bpy.types.Operator):
cached_linework -= edited_guids
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)}
@@ -1037,57 +1040,36 @@ class CreateDrawing(bpy.types.Operator):
files[link.filepath] = (self.get_linked_file(link), link_matrix)
target_view = ifcopenshell.util.element.get_psets(self.camera_element)["EPset_Drawing"]["TargetView"]
self.setup_serialiser(target_view)
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
self.failed_linework_elements: list[tuple[str, str, str]] = []
excluded_guids: set[str] = set()
# 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()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) 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()
ifc_cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
# A failed element corrupts the whole native buffer (#8049), so redo the pass
# from scratch on a fresh serialiser with failing elements excluded each time.
max_attempts = 8
for _ in range(max_attempts):
prior_failure_count = len(self.failed_linework_elements)
tree, raycast_objs, elements_with_faces, results = self.generate_linework_svg(
files, target_view, excluded_guids
)
new_failures = self.failed_linework_elements[prior_failure_count:]
if not new_failures:
break
excluded_guids.update(guid for _, guid, _ in new_failures if guid)
else:
self.report(
{"WARNING"},
f"Linework for '{self.drawing_name}' kept failing on new elements after {max_attempts} attempts; "
"using the last (possibly incomplete) result.",
)
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements:
with profile("Camera element"):
# The camera must always be included, regardless of any include/exclude filters.
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
it = ifcopenshell.geom.iterator(geom_settings, ifc, include=[self.camera_element])
for elem in it:
self.serialiser.write(elem)
with profile("Finalizing"):
self.serialiser.finalize()
results = self.svg_buffer.get_value()
if self.failed_linework_elements:
skipped = "; ".join(f"{t} {g} ({e})" for t, g, e in self.failed_linework_elements)
self.report(
{"WARNING"},
f"{len(self.failed_linework_elements)} element(s) failed to serialize and were skipped "
f"from '{self.drawing_name}': {skipped}",
)
root = etree.fromstring(results)
@@ -1396,6 +1378,74 @@ class CreateDrawing(bpy.types.Operator):
return svg_path
def generate_linework_svg(
self,
files: dict[str, tuple[ifcopenshell.file, Optional[Matrix]]],
target_view: str,
excluded_guids: set[str],
) -> tuple["ifcopenshell.geom.tree", set, set, bytes]:
"""Run one full linework serialisation pass on a fresh serialiser, skipping `excluded_guids`."""
prefs = tool.Blender.get_addon_preferences()
self.setup_serialiser(target_view)
tree = ifcopenshell.geom.tree()
tree.enable_face_styles(True)
# 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()
elements_with_faces = set()
for ifc_path, (ifc, link_matrix) 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()
ifc_cache_path = os.path.join(prefs.cache_dir, f"{ifc_hash}.h5")
self.serialiser.setFile(ifc)
drawing_elements = tool.Drawing.get_drawing_elements(self.camera_element, ifc_file=ifc)
if excluded_guids:
drawing_elements = {e for e in drawing_elements if getattr(e, "GlobalId", None) not in excluded_guids}
if self.cprops.fill_mode == "SHAPELY":
for element in drawing_elements.copy():
if element.is_a("IfcAnnotation"):
continue
obj = tool.Ifc.get_object(element)
if obj and obj.type == "MESH" and len(obj.data.polygons):
elements_with_faces.add(element.GlobalId)
raycast_objs.add(obj)
# Get all representation contexts to see what we're dealing with.
# Drawings only draw bodies and annotations (and facetation, due to a Revit bug).
# A drawing prioritises a target view context first, followed by a model view context as a fallback.
# Specifically for PLAN_VIEW and REFLECTED_PLAN_VIEW, any Plan context is also prioritised.
contexts = self.get_linework_contexts(ifc, target_view)
self.serialize_contexts_elements(ifc, tree, contexts, "body", drawing_elements, target_view, link_matrix)
self.serialize_contexts_elements(
ifc, tree, contexts, "annotation", drawing_elements, target_view, link_matrix
)
camera_excluded = self.camera_element.GlobalId in excluded_guids
if tool.Ifc.get() == ifc and self.camera_element not in drawing_elements and not camera_excluded:
with profile("Camera element"):
# The camera must always be included, regardless of any include/exclude filters.
geom_settings = ifcopenshell.geom.settings()
geom_settings.set("iterator-output", ifcopenshell.ifcopenshell_wrapper.NATIVE)
it = ifcopenshell.geom.iterator(geom_settings, ifc, include=[self.camera_element])
for elem in it:
try:
self.serialiser.write(elem)
except Exception as e:
self.failed_linework_elements.append((elem.type, elem.guid, str(e)))
with profile("Finalizing"):
self.serialiser.finalize()
results = self.svg_buffer.get_value()
return tree, raycast_objs, elements_with_faces, results
def setup_serialiser(self, target_view):
self.svg_settings = ifcopenshell.geom.settings()
self.svg_settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS)