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.
This commit is contained in:
Gorgious56
2026-06-30 14:03:17 +02:00
parent 091fc9e7e5
commit 85cd1c1923
16 changed files with 279 additions and 532 deletions
@@ -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":
@@ -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
)
@@ -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()
@@ -16,8 +16,6 @@
# You should have received a copy of the GNU General Public License
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
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"])
@@ -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):
@@ -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()
+27 -181
View File
@@ -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]
+7 -64
View File
@@ -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":
@@ -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)
@@ -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():
@@ -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()
@@ -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)
+2
View File
@@ -7,9 +7,11 @@ markers =
boundary
brick
bsdd
clash
classification
clip_box
context
contract_guard
cost
covering
debug
@@ -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 <http://www.gnu.org/licenses/>.
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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 == []
@@ -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 <http://www.gnu.org/licenses/>.
#
# 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
)