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) + )