diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py
index bdaf1f48bc..c2f4e8f139 100644
--- a/src/bonsai/bonsai/bim/module/model/decorator.py
+++ b/src/bonsai/bonsai/bim/module/model/decorator.py
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+#
+# This file was modified with the assistance of an AI coding tool.
from __future__ import annotations
@@ -93,15 +95,9 @@ class ProfileDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
- """mutates original bm (triangulates it)
- so the triangulation edges will be shown too
- """
- traingulated_bm = bm
- bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
-
- face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
+ """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
- self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
+ tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
diff --git a/src/bonsai/bonsai/bim/module/model/opening.py b/src/bonsai/bonsai/bim/module/model/opening.py
index c46ac25094..4921da8bbc 100644
--- a/src/bonsai/bonsai/bim/module/model/opening.py
+++ b/src/bonsai/bonsai/bim/module/model/opening.py
@@ -15,6 +15,8 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+#
+# This file was modified with the assistance of an AI coding tool.
from collections.abc import Sequence
from math import radians
@@ -1278,9 +1280,7 @@ class DecorationsHandler:
self.draw_batch("LINES", verts, selected_elements_color, selected_edges)
self.draw_batch("POINTS", unselected_vertices, unselected_elements_color)
self.draw_batch("POINTS", selected_vertices, selected_elements_color)
- obj.data.calc_loop_triangles()
- tris = [tuple(t.vertices) for t in obj.data.loop_triangles]
- self.draw_batch("TRIS", verts, transparent_color(special_elements_color), tris)
+ tool.Blender.draw_bmesh_face_tris(bm, verts, transparent_color(special_elements_color), self.draw_batch)
else:
line_verts, verts, edges_indices, tris = _get_cached_world_draw_data(obj)
color = selected_elements_color if obj in context.selected_objects else special_elements_color
diff --git a/src/bonsai/bonsai/bim/module/system/decorator.py b/src/bonsai/bonsai/bim/module/system/decorator.py
index 13ac7519f1..4c3e810ceb 100644
--- a/src/bonsai/bonsai/bim/module/system/decorator.py
+++ b/src/bonsai/bonsai/bim/module/system/decorator.py
@@ -15,9 +15,10 @@
#
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see .
+#
+# This file was modified with the assistance of an AI coding tool.
-import bmesh
import bpy
import gpu
from bpy.app.handlers import persistent
@@ -78,15 +79,9 @@ class SystemDecorator:
batch.draw(shader)
def draw_faces(self, bm, vertices_coords):
- """mutates original bm (triangulates it)
- so the triangulation edges will be shown too
- """
- traingulated_bm = bm
- bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces)
-
- face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces]
+ """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces."""
faces_color = transparent_color(self.addon_prefs.decorator_color_special)
- self.draw_batch("TRIS", vertices_coords, faces_color, face_indices)
+ tool.Blender.draw_bmesh_face_tris(bm, vertices_coords, faces_color, self.draw_batch)
def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None):
self.addon_prefs = tool.Blender.get_addon_preferences()
diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py
index bb6235a426..94aa316871 100644
--- a/src/bonsai/bonsai/tool/blender.py
+++ b/src/bonsai/bonsai/tool/blender.py
@@ -2242,6 +2242,24 @@ class Blender(bonsai.core.tool.Blender):
return False
return True
+ @classmethod
+ def draw_bmesh_face_tris(
+ cls,
+ bm: bmesh.types.BMesh,
+ world_vert_coords: list,
+ color: Any,
+ draw_batch: Callable[[str, list, Any, list], None],
+ ) -> None:
+ """Submit a non-mutating beauty-triangulated TRIS batch for ``bm``'s faces.
+
+ ``world_vert_coords`` must be indexed by ``bm.verts`` index. Never call
+ ``bmesh.ops.triangulate`` on a live bmesh to compute draw indices — it
+ mutates the input and produces ear-clip fans that render as visible
+ streaks at low alpha.
+ """
+ tris = [[loop.vert.index for loop in tri] for tri in bm.calc_loop_triangles()]
+ draw_batch("TRIS", world_vert_coords, color, tris)
+
@classmethod
def extract_error_reports(cls, exception: RuntimeError) -> list[str]:
"""Extracts error report lines from a runtime exception during operator execution.
diff --git a/src/bonsai/test/bim/test_decorator_no_mutating_triangulate.py b/src/bonsai/test/bim/test_decorator_no_mutating_triangulate.py
new file mode 100644
index 0000000000..05895badb3
--- /dev/null
+++ b/src/bonsai/test/bim/test_decorator_no_mutating_triangulate.py
@@ -0,0 +1,85 @@
+# Bonsai - OpenBIM Blender Add-on
+# Copyright (C) 2026
+#
+# This file is part of Bonsai.
+#
+# Bonsai is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Bonsai is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Bonsai. If not, see .
+#
+# This file was generated with the assistance of an AI coding tool.
+
+"""Forward-compat AST contract: decorators do not triangulate in-place.
+
+``bmesh.ops.triangulate(bm, faces=bm.faces)`` mutates its input — adding tri
+edges and faces — and uses ear-clip fan triangulation that renders as visible
+streaks across n-gon faces at the low alphas decorators favour. The canonical
+draw path is ``tool.Blender.draw_bmesh_face_tris`` (wraps ``bm.calc_loop_triangles``,
+non-mutating, beauty triangulator)."""
+
+import ast
+from pathlib import Path
+
+import pytest
+
+pytestmark = pytest.mark.model
+
+
+BONSAI_ROOT = Path(__file__).parent.parent.parent / "bonsai"
+BIM_MODULE_DIR = BONSAI_ROOT / "bim" / "module"
+
+
+def _iter_guarded_files():
+ yield from sorted(BIM_MODULE_DIR.glob("*/decorator.py"))
+ yield BIM_MODULE_DIR / "model" / "opening.py"
+
+
+def _is_guarded_class(node: ast.ClassDef) -> bool:
+ return node.name.endswith("Decorator") or node.name == "DecorationsHandler"
+
+
+def _is_mutating_triangulate_call(node: ast.AST) -> bool:
+ if not isinstance(node, ast.Call):
+ return False
+ func = node.func
+ if not isinstance(func, ast.Attribute) or func.attr != "triangulate":
+ return False
+ receiver = func.value
+ if not isinstance(receiver, ast.Attribute) or receiver.attr != "ops":
+ return False
+ inner = receiver.value
+ return isinstance(inner, ast.Name) and inner.id == "bmesh"
+
+
+def test_no_decorator_calls_bmesh_ops_triangulate() -> None:
+ violations: list[str] = []
+ guarded_files = list(_iter_guarded_files())
+ assert guarded_files, "Search root contains no decorator modules — test needs updating."
+
+ for path in guarded_files:
+ try:
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ except (SyntaxError, FileNotFoundError):
+ continue
+ for class_node in (n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)):
+ if not _is_guarded_class(class_node):
+ continue
+ for sub in ast.walk(class_node):
+ if _is_mutating_triangulate_call(sub):
+ violations.append(f"{path}:{sub.lineno} {class_node.name} calls bmesh.ops.triangulate")
+
+ assert not violations, (
+ "Decorator classes must not call bmesh.ops.triangulate — it mutates "
+ "the input bmesh and produces fan-clip artefacts at low alpha. "
+ "Use tool.Blender.draw_bmesh_face_tris (wraps bm.calc_loop_triangles). "
+ "Violations:\n " + "\n ".join(violations)
+ )