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