From 85cd1c1923019219ab2a9f6a5d4a6c65a042fcd3 Mon Sep 17 00:00:00 2001 From: Gorgious56 Date: Tue, 30 Jun 2026 14:03:17 +0200 Subject: [PATCH] Bonsai: migrate viewport decorators onto canonical base Migrate 17 legacy viewport decorators (ClashDecorator, SolarDecorator, MeasureDecorator, ItemDecorator, GeoreferenceDecorator, NestDecorator, NestModeDecorator, GridDecorator, LoadsDecorator, AggregateDecorator, AggregateModeDecorator, PolylineDecorator, ProductDecorator, WallAxisDecorator, SlabDirectionDecorator, FaceAreaDecorator, BoundingBoxDecorator) from hand-rolled install/uninstall lifecycles onto the canonical tool.Blender.ViewportDecorator base. The legacy uninstall removed each handler from Blender but never cleared cls.handlers, growing a stale-reference list across enable/disable cycles. The base's uninstall clears the list correctly. State-derived install methods (ItemDecorator, ProductDecorator, LoadsDecorator, PolylineDecorator) keep an install override per the base's documented contract. Drop the now-redundant per-class draw_batch copies and the module- or method-scope transparent_color defs in favour of the base helpers introduced in the preceding commit. system/decorator.py and boundary/decorator.py keep their installed-flag lifecycle (different pattern, no leak) but consume tool.Blender.transparent_color. Add an AST forward-compat guard pinning the contract structurally: any class declaring handlers = [] (Assign or AnnAssign) must subclass tool.Blender.ViewportDecorator. Add a runtime regression on ClashDecorator's install/uninstall cycle. Generated with the assistance of an AI coding tool. --- .../bonsai/bim/module/aggregate/decorator.py | 75 +------ .../bonsai/bim/module/boundary/decorator.py | 13 +- .../bonsai/bim/module/clash/decorator.py | 36 +-- .../bonsai/bim/module/geometry/decorator.py | 59 +++-- .../bim/module/georeference/decorator.py | 27 +-- .../bonsai/bim/module/light/decorator.py | 36 +-- .../bonsai/bim/module/model/decorator.py | 208 +++--------------- .../bonsai/bim/module/nest/decorator.py | 71 +----- .../bonsai/bim/module/project/decorator.py | 58 ++--- .../bonsai/bim/module/spatial/decorator.py | 36 +-- .../bonsai/bim/module/structural/decorator.py | 21 +- .../bonsai/bim/module/system/decorator.py | 14 +- src/bonsai/pytest.ini | 2 + src/bonsai/test/bim/module/clash/__init__.py | 17 ++ .../test_clash_decorator_handlers_cleared.py | 52 +++++ ...decorator_handlers_clear_forward_compat.py | 86 ++++++++ 16 files changed, 279 insertions(+), 532 deletions(-) create mode 100644 src/bonsai/test/bim/module/clash/__init__.py create mode 100644 src/bonsai/test/bim/module/clash/test_clash_decorator_handlers_cleared.py create mode 100644 src/bonsai/test/bim/test_decorator_handlers_clear_forward_compat.py diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py index eb389a58bd..f13f189641 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py @@ -20,7 +20,6 @@ import blf import bpy import gpu import ifcopenshell.util.element -from bpy.types import SpaceView3D from bpy_extras import view3d_utils from gpu_extras.batch import batch_for_shader from mathutils import Vector @@ -28,12 +27,6 @@ from mathutils import Vector import bonsai.tool as tool -def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - - def create_bounding_box(objs): # Initialize the bounding box coordinates min_x, min_y, min_z = float("inf"), float("inf"), float("inf") @@ -79,26 +72,8 @@ def create_bounding_box(objs): return indices, edges -class AggregateDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_aggregate, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False +class AggregateDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_aggregate" def dotted_line_shader(self): vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") @@ -153,14 +128,6 @@ class AggregateDecorator: shader.uniform_float("u_Scale", 25) batch.draw(shader) - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def draw_aggregate(self, context): props = tool.Aggregate.get_aggregate_props() self.addon_prefs = tool.Blender.get_addon_preferences() @@ -225,39 +192,11 @@ class AggregateDecorator: self.draw_custom_batch(line, decorator_color_unselected) -class AggregateModeDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_aggregate_name, (context,), "WINDOW", "POST_PIXEL") - ) - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW") - ) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class AggregateModeDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_aggregate_name", "POST_PIXEL"), + ("draw_aggregate_empty", "POST_VIEW"), + ) def draw_aggregate_name(self, context): if context.mode == "EDIT_MESH": diff --git a/src/bonsai/bonsai/bim/module/boundary/decorator.py b/src/bonsai/bonsai/bim/module/boundary/decorator.py index a2d134a135..c879dc72db 100644 --- a/src/bonsai/bonsai/bim/module/boundary/decorator.py +++ b/src/bonsai/bonsai/bim/module/boundary/decorator.py @@ -56,11 +56,6 @@ class BoundaryDecorator: unselected_elements_color = self.addon_prefs.decorator_color_unselected special_elements_color = self.addon_prefs.decorator_color_special - def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - gpu.state.point_size_set(6) gpu.state.blend_set("ALPHA") @@ -109,7 +104,11 @@ class BoundaryDecorator: if unselected_edges: self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges) - self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris) + self.draw_batch( + "TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris + ) if selected_edges: self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges) - self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris) + self.draw_batch( + "TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris + ) diff --git a/src/bonsai/bonsai/bim/module/clash/decorator.py b/src/bonsai/bonsai/bim/module/clash/decorator.py index ab95c92d9f..6e5baa9f45 100644 --- a/src/bonsai/bonsai/bim/module/clash/decorator.py +++ b/src/bonsai/bonsai/bim/module/clash/decorator.py @@ -18,43 +18,17 @@ import blf import gpu -from bpy.types import SpaceView3D from bpy_extras.view3d_utils import location_3d_to_region_2d -from gpu_extras.batch import batch_for_shader from mathutils import Vector import bonsai.tool as tool -class ClashDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class ClashDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_text", "POST_PIXEL"), + ("draw_geometry", "POST_VIEW"), + ) def draw_text(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() diff --git a/src/bonsai/bonsai/bim/module/geometry/decorator.py b/src/bonsai/bonsai/bim/module/geometry/decorator.py index 589b18ec84..2da7162fc7 100644 --- a/src/bonsai/bonsai/bim/module/geometry/decorator.py +++ b/src/bonsai/bonsai/bim/module/geometry/decorator.py @@ -16,8 +16,6 @@ # You should have received a copy of the GNU General Public License # along with Bonsai. If not, see . -from collections.abc import Sequence - import blf import bpy import gpu @@ -25,15 +23,16 @@ import ifcopenshell import numpy as np from bpy.types import SpaceView3D from bpy_extras.view3d_utils import location_3d_to_region_2d -from gpu_extras.batch import batch_for_shader from mathutils import Matrix, Vector import bonsai.tool as tool -class ItemDecorator: - is_installed = False - handlers = [] +class ItemDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_text", "POST_PIXEL"), + ("draw", "POST_VIEW"), + ) objs: dict[str, dict[str, list]] obj_is_selected: dict[str, bool] obj_is_boolean: dict[str, list[ifcopenshell.entity_instance]] @@ -119,23 +118,6 @@ class ItemDecorator: "special_edges": special_edges, } - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def draw_text(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() selected_elements_color = self.addon_prefs.decorator_color_selected @@ -163,11 +145,6 @@ class ItemDecorator: blf.disable(font_id, blf.SHADOW) def draw(self, context: bpy.types.Context) -> None: - def transparent_color(color: Sequence[float], alpha: float = 0.05) -> list[float]: - color = [i for i in color] - color[3] = alpha - return color - self.addon_prefs = tool.Blender.get_addon_preferences() selected_elements_color = self.addon_prefs.decorator_color_selected unselected_elements_color = self.addon_prefs.decorator_color_unselected @@ -197,15 +174,33 @@ class ItemDecorator: if context.mode != "OBJECT": continue self.draw_batch("LINES", data["verts"], selected_elements_color, data["edges"]) - self.draw_batch("TRIS", data["verts"], transparent_color(selected_elements_color), data["tris"]) + self.draw_batch( + "TRIS", + data["verts"], + tool.Blender.transparent_color(selected_elements_color, alpha=0.05), + data["tris"], + ) self.draw_batch("LINES", data["special_verts"], selected_elements_color, data["special_edges"]) elif self.obj_is_boolean[obj_name]: self.draw_batch("LINES", data["verts"], special_elements_color, data["edges"]) - self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"]) + self.draw_batch( + "TRIS", + data["verts"], + tool.Blender.transparent_color(special_elements_color, alpha=0.05), + data["tris"], + ) self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"]) else: self.draw_batch( - "LINES", data["verts"], transparent_color(unselected_elements_color, alpha=0.2), data["edges"] + "LINES", + data["verts"], + tool.Blender.transparent_color(unselected_elements_color, alpha=0.2), + data["edges"], + ) + self.draw_batch( + "TRIS", + data["verts"], + tool.Blender.transparent_color(special_elements_color, alpha=0.05), + data["tris"], ) - self.draw_batch("TRIS", data["verts"], transparent_color(special_elements_color), data["tris"]) self.draw_batch("LINES", data["special_verts"], special_elements_color, data["special_edges"]) diff --git a/src/bonsai/bonsai/bim/module/georeference/decorator.py b/src/bonsai/bonsai/bim/module/georeference/decorator.py index 05bce0e20b..c0b9ef0b27 100644 --- a/src/bonsai/bonsai/bim/module/georeference/decorator.py +++ b/src/bonsai/bonsai/bim/module/georeference/decorator.py @@ -21,7 +21,6 @@ from math import radians import blf import gpu import ifcopenshell.util.geolocation -from bpy.types import SpaceView3D from bpy_extras.view3d_utils import location_3d_to_region_2d from gpu_extras.batch import batch_for_shader from mathutils import Matrix, Vector @@ -30,27 +29,11 @@ import bonsai.tool as tool from bonsai.bim.module.georeference.data import GeoreferenceData -class GeoreferenceDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False +class GeoreferenceDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_text", "POST_PIXEL"), + ("draw_geometry", "POST_VIEW"), + ) def draw_batch(self, shader_type, content_pos, color, indices=None, should_scale=True): if not tool.Blender.validate_shader_batch_data(content_pos, indices): diff --git a/src/bonsai/bonsai/bim/module/light/decorator.py b/src/bonsai/bonsai/bim/module/light/decorator.py index f72a941ab8..829657180f 100644 --- a/src/bonsai/bonsai/bim/module/light/decorator.py +++ b/src/bonsai/bonsai/bim/module/light/decorator.py @@ -20,44 +20,18 @@ import blf import bpy import gpu -from bpy.types import SpaceView3D from bpy_extras.view3d_utils import location_3d_to_region_2d -from gpu_extras.batch import batch_for_shader from mathutils import Matrix, Vector import bonsai.tool as tool from bonsai.bim.module.light.data import SolarData -class SolarDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_geometry, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class SolarDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_text", "POST_PIXEL"), + ("draw_geometry", "POST_VIEW"), + ) def draw_text(self, context: bpy.types.Context) -> None: self.addon_prefs = tool.Blender.get_addon_preferences() diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 8ec53af5a8..65691f6edf 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -53,12 +53,6 @@ from bonsai.bim.module.drawing.gizmos import ( from bonsai.bim.module.drawing.helper import format_distance -def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - - def highlight_color(color, alpha=0.1): color = [i + (1 - i) * 0.5 for i in color] return color @@ -133,7 +127,7 @@ class ProfileDecorator: def draw_faces(self, bm, vertices_coords): """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces.""" - faces_color = transparent_color(self.addon_prefs.decorator_color_special) + faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special) 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): @@ -263,7 +257,7 @@ class ProfileDecorator: self.draw_batch("LINES", all_vertices, unselected_elements_color, unselected_edges) self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges) - self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5)) + self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5)) self.draw_batch("POINTS", error_vertices, error_elements_color) self.draw_batch("POINTS", special_vertices, special_elements_color) self.draw_batch("POINTS", selected_vertices, selected_elements_color) @@ -354,9 +348,11 @@ class ProfileDecorator: return points, listEdg -class PolylineDecorator: - is_installed = False - handlers = [] +class PolylineDecorator(tool.Blender.ViewportDecorator): + # draw_methods declares only the always-bound handler so the base's + # __init_subclass__ validation passes; the override install below + # conditionally registers up to four more handlers based on ui_only. + draw_methods = (("draw_input_ui", "POST_PIXEL"),) event = None input_type = None input_ui = None @@ -392,15 +388,6 @@ class PolylineDecorator: cls.handlers.append(SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - @classmethod def update( cls, @@ -459,14 +446,6 @@ class PolylineDecorator: return {"verts": verts, "edges": edges, "tris": tris} - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def shader_config(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() self.decorator_color = self.addon_prefs.decorations_colour @@ -734,7 +713,9 @@ class PolylineDecorator: if self.polyline_data.measurement_type == "POLY_AREA" and area: if float(area) > 0: tris = self.calculate_polygon(polyline_verts)["tris"] - self.draw_batch("TRIS", polyline_verts, transparent_color(self.decorator_color_special), tris) + self.draw_batch( + "TRIS", polyline_verts, tool.Blender.transparent_color(self.decorator_color_special), tris + ) # Draw polyline with selected points self.line_shader.uniform_float("lineWidth", 2.0) @@ -987,9 +968,8 @@ class PolylineDecorator: self.draw_batch("LINES", polyline_verts, decorator_color_unselected, polyline_edges) -class ProductDecorator: - is_installed = False - handlers = [] +class ProductDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_product_preview" preview_mode: Literal["PROFILE_VERTICAL", "PROFILE_HORIZONTAL", "LAYER2", "LAYER3", "GENERIC"] relating_type = None obj_data: dict[str, list] = {} @@ -1032,29 +1012,7 @@ class ProductDecorator: ) cls.is_installed = True - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def draw_product_preview(self, context): - def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - self.addon_prefs = tool.Blender.get_addon_preferences() self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") self.line_shader.bind() # required to be able to change uniforms of the shader @@ -1086,7 +1044,7 @@ class ProductDecorator: data = self.get_generic_preview_data() if data: self.draw_batch("LINES", data["verts"], decorator_color, data["edges"]) - self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color), data["tris"]) + self.draw_batch("TRIS", data["verts"], tool.Blender.transparent_color(decorator_color), data["tris"]) def get_wall_preview_data(self): relating_type = self.relating_type @@ -1619,34 +1577,8 @@ class ProductDecorator: return data -class WallAxisDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_wall_axis, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class WallAxisDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_wall_axis" def draw_wall_axis(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() @@ -1685,34 +1617,8 @@ class WallAxisDecorator: self.draw_batch("LINES", arrow, unselected_elements_color, [(0, 1), (1, 2), (1, 3)]) -class SlabDirectionDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_wall_axis, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class SlabDirectionDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_wall_axis" def draw_wall_axis(self, context): self.addon_prefs = tool.Blender.get_addon_preferences() @@ -1742,41 +1648,10 @@ class SlabDirectionDecorator: self.draw_batch("LINES", base, selected_elements_color, [(0, 1)]) -class FaceAreaDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_face_area, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class FaceAreaDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_face_area" def draw_face_area(self, context): - def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - self.addon_prefs = tool.Blender.get_addon_preferences() self.line_shader = gpu.shader.from_builtin("POLYLINE_UNIFORM_COLOR") self.line_shader.bind() # required to be able to change uniforms of the shader @@ -1797,12 +1672,16 @@ class FaceAreaDecorator: if data: self.draw_batch("POINTS", data["verts"], decorator_color) self.draw_batch("LINES", data["verts"], decorator_color, data["edges"]) - self.draw_batch("TRIS", data["verts"], transparent_color(decorator_color, alpha=0.5), data["tris"]) + self.draw_batch( + "TRIS", data["verts"], tool.Blender.transparent_color(decorator_color, alpha=0.5), data["tris"] + ) -class BoundingBoxDecorator: - is_installed = False - handlers = [] +class BoundingBoxDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_bounding_box_wire_cube", "POST_VIEW"), + ("draw_dimension_text", "POST_PIXEL"), + ) def __init__(self): context = bpy.context @@ -1813,31 +1692,6 @@ class BoundingBoxDecorator: self.decorator_color_wire = (*theme.view_3d.bone_solid, 1) self.decorator_color_special = tool.Blender.get_addon_preferences().decorator_color_special - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append( - bpy.types.SpaceView3D.draw_handler_add( - handler.draw_bounding_box_wire_cube, (context,), "WINDOW", "POST_VIEW" - ) - ) - cls.handlers.append( - bpy.types.SpaceView3D.draw_handler_add(handler.draw_dimension_text, (context,), "WINDOW", "POST_PIXEL") - ) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - bpy.types.SpaceView3D.draw_handler_remove(handler, "WINDOW") - except Exception: - pass - cls.handlers.clear() - cls.is_installed = False - @staticmethod def get_combined_bounding_box_corners(objects): @@ -1910,14 +1764,6 @@ class BoundingBoxDecorator: ] return trihedron[best_origin] - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def draw_text_background(self, context, coords_dim, text_dim): padding = 5 theme = context.preferences.themes.items()[0][1] diff --git a/src/bonsai/bonsai/bim/module/nest/decorator.py b/src/bonsai/bonsai/bim/module/nest/decorator.py index 28c3835ba7..346a470172 100644 --- a/src/bonsai/bonsai/bim/module/nest/decorator.py +++ b/src/bonsai/bonsai/bim/module/nest/decorator.py @@ -20,7 +20,6 @@ import blf import bpy import gpu import ifcopenshell.util.element -from bpy.types import SpaceView3D from bpy_extras import view3d_utils from gpu_extras.batch import batch_for_shader from mathutils import Vector @@ -28,12 +27,6 @@ from mathutils import Vector import bonsai.tool as tool -def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - - def create_bounding_box(objs): # Initialize the bounding box coordinates min_x, min_y, min_z = float("inf"), float("inf"), float("inf") @@ -79,26 +72,8 @@ def create_bounding_box(objs): return indices, edges -class NestDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False +class NestDecorator(tool.Blender.ViewportDecorator): + draw_method = "draw_nest" def dotted_line_shader(self): vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") @@ -154,14 +129,6 @@ class NestDecorator: shader.uniform_float("u_Scale", 25) batch.draw(shader) - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) - def draw_nest(self, context: bpy.types.Context) -> None: props = tool.Nest.get_nest_props() if props.in_nest_mode: @@ -226,35 +193,11 @@ class NestDecorator: self.draw_custom_batch(line, decorator_color_unselected) -class NestModeDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_name, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_nest_empty, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class NestModeDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_nest_name", "POST_PIXEL"), + ("draw_nest_empty", "POST_VIEW"), + ) def draw_nest_name(self, context): if context.mode == "EDIT_MESH": diff --git a/src/bonsai/bonsai/bim/module/project/decorator.py b/src/bonsai/bonsai/bim/module/project/decorator.py index 72090a769b..7af79d3add 100644 --- a/src/bonsai/bonsai/bim/module/project/decorator.py +++ b/src/bonsai/bonsai/bim/module/project/decorator.py @@ -42,12 +42,6 @@ def toggle_decorations_on_load(*args): # as queried object is linked from separate .blend file. -def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - - class ProjectDecorator: installed = None @@ -80,11 +74,6 @@ class ProjectDecorator: unselected_elements_color = self.addon_prefs.decorator_color_unselected special_elements_color = self.addon_prefs.decorator_color_special - def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - gpu.state.point_size_set(6) gpu.state.blend_set("ALPHA") @@ -110,7 +99,9 @@ class ProjectDecorator: if geom.selected_edges: self.draw_batch("LINES", selected_vertices, selected_elements_color, geom.selected_edges) - self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), geom.selected_tris) + self.draw_batch( + "TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), geom.selected_tris + ) class ClippingPlaneDecorator: @@ -145,11 +136,6 @@ class ClippingPlaneDecorator: unselected_elements_color = self.addon_prefs.decorator_color_unselected special_elements_color = self.addon_prefs.decorator_color_special - def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - gpu.state.point_size_set(6) gpu.state.blend_set("ALPHA") @@ -210,37 +196,21 @@ class ClippingPlaneDecorator: if unselected_edges: self.draw_batch("LINES", unselected_vertices, special_elements_color, unselected_edges) - self.draw_batch("TRIS", unselected_vertices, transparent_color(special_elements_color), unselected_tris) + self.draw_batch( + "TRIS", unselected_vertices, tool.Blender.transparent_color(special_elements_color), unselected_tris + ) if selected_edges: self.draw_batch("LINES", selected_vertices, selected_elements_color, selected_edges) - self.draw_batch("TRIS", selected_vertices, transparent_color(selected_elements_color), selected_tris) + self.draw_batch( + "TRIS", selected_vertices, tool.Blender.transparent_color(selected_elements_color), selected_tris + ) -class MeasureDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_measurements_text, (context,), "WINDOW", "POST_PIXEL") - ) - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_measurements_poly, (context,), "WINDOW", "POST_VIEW") - ) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False +class MeasureDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_measurements_text", "POST_PIXEL"), + ("draw_measurements_poly", "POST_VIEW"), + ) def draw_measurements_text(self, context): PolylineDecorator().select_and_draw_measurements_text(context) diff --git a/src/bonsai/bonsai/bim/module/spatial/decorator.py b/src/bonsai/bonsai/bim/module/spatial/decorator.py index 047a9ff555..6f97ae86d9 100644 --- a/src/bonsai/bonsai/bim/module/spatial/decorator.py +++ b/src/bonsai/bonsai/bim/module/spatial/decorator.py @@ -18,43 +18,17 @@ import blf import gpu -from bpy.types import SpaceView3D from bpy_extras.view3d_utils import location_3d_to_region_2d -from gpu_extras.batch import batch_for_shader from mathutils import Vector import bonsai.tool as tool -class GridDecorator: - is_installed = False - handlers = [] - - @classmethod - def install(cls, context): - if cls.is_installed: - cls.uninstall() - handler = cls() - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_text, (context,), "WINDOW", "POST_PIXEL")) - cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw, (context,), "WINDOW", "POST_VIEW")) - cls.is_installed = True - - @classmethod - def uninstall(cls): - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - - def draw_batch(self, shader_type, content_pos, color, indices=None): - if not tool.Blender.validate_shader_batch_data(content_pos, indices): - return - shader = self.line_shader if shader_type == "LINES" else self.shader - batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) - shader.uniform_float("color", color) - batch.draw(shader) +class GridDecorator(tool.Blender.ViewportDecorator): + draw_methods = ( + ("draw_text", "POST_PIXEL"), + ("draw", "POST_VIEW"), + ) def draw_text(self, context): if not tool.Blender.is_addon_enabled(): diff --git a/src/bonsai/bonsai/bim/module/structural/decorator.py b/src/bonsai/bonsai/bim/module/structural/decorator.py index 32925be61c..3de45bd0e5 100644 --- a/src/bonsai/bonsai/bim/module/structural/decorator.py +++ b/src/bonsai/bonsai/bim/module/structural/decorator.py @@ -31,11 +31,17 @@ import bonsai.tool as tool from bonsai.bim.module.structural.load_decoration_data import ShaderInfo -class LoadsDecorator: +class LoadsDecorator(tool.Blender.ViewportDecorator): """Decorator to show structural loads in 3D""" - is_installed = False - handlers = [] + # draw_methods exists to satisfy ViewportDecorator.__init_subclass__'s + # method-existence check; the override install below is what actually + # registers handlers (the POST_VIEW binding passes no context arg, which + # the base's generic install cannot express). + draw_methods = ( + ("draw_load_values", "POST_PIXEL"), + ("__call__", "POST_VIEW"), + ) decoration_data = None text_info = [] shader_info = [] @@ -54,15 +60,6 @@ class LoadsDecorator: cls.update() cls.is_installed = True - @classmethod - def uninstall(cls) -> None: - for handler in cls.handlers: - try: - SpaceView3D.draw_handler_remove(handler, "WINDOW") - except ValueError: - pass - cls.is_installed = False - @classmethod def update(cls) -> None: cls.decoration_data.update() diff --git a/src/bonsai/bonsai/bim/module/system/decorator.py b/src/bonsai/bonsai/bim/module/system/decorator.py index 4c3e810ceb..1770f99e86 100644 --- a/src/bonsai/bonsai/bim/module/system/decorator.py +++ b/src/bonsai/bonsai/bim/module/system/decorator.py @@ -31,12 +31,6 @@ ERROR_ELEMENTS_COLOR = (1, 0.2, 0.322, 1) # RED UNSPECIAL_ELEMENT_COLOR = (0.2, 0.2, 0.2, 1) # GREY -def transparent_color(color, alpha=0.1): - color = [i for i in color] - color[3] = alpha - return color - - @persistent def toggle_decorations_on_load(*args): props = tool.System.get_system_props() @@ -80,7 +74,7 @@ class SystemDecorator: def draw_faces(self, bm, vertices_coords): """Submit a non-mutating beauty-triangulated TRIS batch over ``bm``'s faces.""" - faces_color = transparent_color(self.addon_prefs.decorator_color_special) + faces_color = tool.Blender.transparent_color(self.addon_prefs.decorator_color_special) 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): @@ -128,13 +122,15 @@ class SystemDecorator: self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") self.shader.bind() - self.draw_batch("LINES", all_vertices, transparent_color(unselected_elements_color), unselected_edges) + self.draw_batch( + "LINES", all_vertices, tool.Blender.transparent_color(unselected_elements_color), unselected_edges + ) self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges) self.draw_batch("LINES", all_vertices, UNSPECIAL_ELEMENT_COLOR, arc_edges) self.draw_batch("LINES", all_vertices, special_elements_color, preview_edges) self.draw_batch("LINES", all_vertices, special_elements_color, roof_angle_edges) - self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5)) + self.draw_batch("POINTS", unselected_vertices, tool.Blender.transparent_color(unselected_elements_color, 0.5)) self.draw_batch("POINTS", error_vertices, ERROR_ELEMENTS_COLOR) self.draw_batch("POINTS", special_vertices, special_elements_color) self.draw_batch("POINTS", selected_vertices, selected_elements_color) diff --git a/src/bonsai/pytest.ini b/src/bonsai/pytest.ini index f4dbb884b6..43c5f82d32 100644 --- a/src/bonsai/pytest.ini +++ b/src/bonsai/pytest.ini @@ -7,9 +7,11 @@ markers = boundary brick bsdd + clash classification clip_box context + contract_guard cost covering debug diff --git a/src/bonsai/test/bim/module/clash/__init__.py b/src/bonsai/test/bim/module/clash/__init__.py new file mode 100644 index 0000000000..6fe44a6223 --- /dev/null +++ b/src/bonsai/test/bim/module/clash/__init__.py @@ -0,0 +1,17 @@ +# 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 . diff --git a/src/bonsai/test/bim/module/clash/test_clash_decorator_handlers_cleared.py b/src/bonsai/test/bim/module/clash/test_clash_decorator_handlers_cleared.py new file mode 100644 index 0000000000..d79956f6f5 --- /dev/null +++ b/src/bonsai/test/bim/module/clash/test_clash_decorator_handlers_cleared.py @@ -0,0 +1,52 @@ +# 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. + +"""Runtime regression: viewport-decorator install / uninstall keeps the +``handlers`` list empty across repeated cycles. + +ClashDecorator is the representative subclass — its lifecycle is now +inherited from ``tool.Blender.ViewportDecorator``. The contract pinned +here is the canonical one for every subclass: after each ``uninstall``, +``cls.handlers`` must be empty and ``cls.is_installed`` must be False.""" + +import bpy +import pytest + +from bonsai.bim.module.clash.decorator import ClashDecorator + +pytestmark = pytest.mark.clash + + +@pytest.fixture(autouse=True) +def _reset_decorator_state(): + ClashDecorator.uninstall() + yield + ClashDecorator.uninstall() + + +def test_clash_decorator_handlers_cleared_across_install_cycles(): + ctx = bpy.context + for _ in range(3): + ClashDecorator.install(ctx) + assert ClashDecorator.is_installed is True + assert len(ClashDecorator.handlers) > 0 + ClashDecorator.uninstall() + assert ClashDecorator.is_installed is False + assert ClashDecorator.handlers == [] diff --git a/src/bonsai/test/bim/test_decorator_handlers_clear_forward_compat.py b/src/bonsai/test/bim/test_decorator_handlers_clear_forward_compat.py new file mode 100644 index 0000000000..9f9cb011e2 --- /dev/null +++ b/src/bonsai/test/bim/test_decorator_handlers_clear_forward_compat.py @@ -0,0 +1,86 @@ +# 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 for viewport decorator lifecycle. + +Any class that tracks Blender draw handlers via a class-level ``handlers`` +list MUST subclass ``tool.Blender.ViewportDecorator``. The base sets +``handlers = []`` and ``is_installed = False`` via ``__init_subclass__`` and +provides install / uninstall with the correct ``cls.handlers.clear()``. +A class that declares its own ``handlers = []`` outside the base duplicates +the lifecycle and is at risk of regressing the handler-clear bug class.""" + +import ast +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.contract_guard + +ADDON_ROOT = Path(__file__).parent.parent.parent / "bonsai" +DECORATORS_GLOB = "bim/module/**/decorator.py" + + +def _is_empty_handlers_list(target: ast.expr, value: ast.expr | None) -> bool: + return isinstance(target, ast.Name) and target.id == "handlers" and isinstance(value, ast.List) and not value.elts + + +def _has_handlers_list_class_attr(class_node: ast.ClassDef) -> bool: + for node in class_node.body: + if isinstance(node, ast.Assign): + for target in node.targets: + if _is_empty_handlers_list(target, node.value): + return True + elif isinstance(node, ast.AnnAssign): + if _is_empty_handlers_list(node.target, node.value): + return True + return False + + +def _subclasses_viewport_decorator(class_node: ast.ClassDef) -> bool: + for base in class_node.bases: + if isinstance(base, ast.Name) and base.id.endswith("ViewportDecorator"): + return True + if isinstance(base, ast.Attribute) and base.attr.endswith("ViewportDecorator"): + return True + return False + + +def test_no_decorator_class_duplicates_viewport_lifecycle() -> None: + offenders: list[str] = [] + for path in sorted(ADDON_ROOT.glob(DECORATORS_GLOB)): + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if not isinstance(node, ast.ClassDef): + continue + if not _has_handlers_list_class_attr(node): + continue + if _subclasses_viewport_decorator(node): + continue + rel = path.relative_to(ADDON_ROOT) + offenders.append(f"{rel.as_posix()}:{node.lineno}: class {node.name}") + if offenders: + listing = "\n ".join(offenders) + pytest.fail( + "Class(es) declare ``handlers = []`` at class scope without subclassing " + "``tool.Blender.ViewportDecorator``. Migrate to the canonical viewport-lifecycle " + "base (which sets handlers/is_installed via __init_subclass__ and provides " + "install/uninstall with the correct cls.handlers.clear()):\n " + listing + )