mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 02:02:22 +00:00
Extend tool.Blender for parametric framework + decorators
Adds:
* ViewportDecorator base class — install/uninstall/draw lifecycle for
3D viewport gpu overlays, with handler-rollback-on-failure so a
partial install can't leave dangling draw handlers.
* sync_all classmethod — drive each listed ViewportDecorator subclass
to its desired install state in one call.
* is_view_top_down + top_down_factor — viewport-camera orientation
predicates used by gizmo billboarding and decorator layout.
* get_screen_up_world — screen-up vector in world space for gizmo
text orientation.
* are_viewport_gizmos_enabled — central gate for the global
draw_gizmos_in_3d_viewport pref, replacing duplicated prefs reads.
* DecoratorColors NamedTuple + get_decorator_colors — single source
for the colour palette every viewport decorator binds.
Preserves Ryan Schultz's add_layout_hotkey_operator polish (719309571,
2026-05-25): the row-position move + separator(factor=1) between the
modifier and key icons stay intact in this extraction.
Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
4a70250c68
commit
6c4414aa4e
+245
-116
@@ -22,7 +22,6 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
@@ -30,7 +29,7 @@ import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
import types
|
||||
from collections.abc import Callable, Generator, Iterable, Sequence, Sized
|
||||
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Sized
|
||||
from datetime import datetime
|
||||
from functools import cache, lru_cache
|
||||
from pathlib import Path
|
||||
@@ -47,7 +46,6 @@ from typing import (
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
import ifcopenshell.api
|
||||
import ifcopenshell.util.element
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -99,6 +97,19 @@ VIEWPORT_ATTRIBUTES = [
|
||||
|
||||
OBJECT_DATA_TYPE = Union[bpy.types.Mesh, bpy.types.Curve, bpy.types.Camera]
|
||||
|
||||
_RAILING_MODIFIER_IFC_CLASSES = ("IfcRailing", "IfcRailingType")
|
||||
_STAIR_MODIFIER_IFC_CLASSES = (
|
||||
"IfcStairFlight",
|
||||
"IfcStairFlightType",
|
||||
"IfcMember",
|
||||
"IfcMemberType",
|
||||
"IfcStair",
|
||||
"IfcStairType",
|
||||
)
|
||||
_WINDOW_MODIFIER_IFC_CLASSES = ("IfcWindow", "IfcWindowType", "IfcWindowStyle")
|
||||
_DOOR_MODIFIER_IFC_CLASSES = ("IfcDoor", "IfcDoorType", "IfcDoorStyle")
|
||||
_ROOF_MODIFIER_IFC_CLASSES = ("IfcRoof", "IfcRoofType")
|
||||
|
||||
|
||||
class Blender(bonsai.core.tool.Blender):
|
||||
OBJECT_TYPES_THAT_SUPPORT_EDIT_MODE = ("MESH", "CURVE", "SURFACE", "META", "FONT", "LATTICE", "ARMATURE")
|
||||
@@ -417,6 +428,189 @@ class Blender(bonsai.core.tool.Blender):
|
||||
with bpy.context.temp_override(**cls.get_viewport_context()):
|
||||
bpy.ops.wm.tool_set_by_id(name=tool_name)
|
||||
|
||||
@classmethod
|
||||
def are_viewport_gizmos_enabled(cls) -> bool:
|
||||
"""Central gate every Bonsai gizmo poll / decorator draw checks before
|
||||
rendering. Centralises the read of
|
||||
``gizmos.draw_gizmos_in_3d_viewport`` from addon preferences."""
|
||||
return cls.get_addon_preferences().gizmos.draw_gizmos_in_3d_viewport
|
||||
|
||||
class DecoratorColors(NamedTuple):
|
||||
selected: tuple
|
||||
unselected: tuple
|
||||
special: tuple
|
||||
error: tuple
|
||||
background: tuple
|
||||
|
||||
@classmethod
|
||||
def get_decorator_colors(cls) -> Blender.DecoratorColors:
|
||||
"""The five ``decorator_color_*`` fields read together so each viewport
|
||||
decorator's draw callback resolves them in one call instead of five."""
|
||||
prefs = cls.get_addon_preferences()
|
||||
return cls.DecoratorColors(
|
||||
selected=prefs.decorator_color_selected,
|
||||
unselected=prefs.decorator_color_unselected,
|
||||
special=prefs.decorator_color_special,
|
||||
error=prefs.decorator_color_error,
|
||||
background=prefs.decorator_color_background,
|
||||
)
|
||||
|
||||
class ViewportDecorator:
|
||||
"""Shared ``SpaceView3D.draw_handler_add`` lifecycle for feature decorators.
|
||||
|
||||
Single-handler subclasses set ``draw_method`` (default ``"draw"``); the
|
||||
handler binds at ``POST_VIEW``. Multi-handler subclasses set
|
||||
``draw_methods`` to a tuple of ``(method_name, phase)`` pairs; when it
|
||||
is non-``None`` it supersedes ``draw_method``.
|
||||
|
||||
Decorators whose ``install`` must accept extra arguments (e.g. a callback
|
||||
or a precomputed bmesh) override ``install`` themselves."""
|
||||
|
||||
draw_method: str = "draw"
|
||||
draw_methods: tuple[tuple[str, str], ...] | None = None
|
||||
|
||||
def __init_subclass__(cls, **kwargs):
|
||||
super().__init_subclass__(**kwargs)
|
||||
cls.handlers = []
|
||||
cls.is_installed = False
|
||||
# Fail loudly at class-definition time if draw_method / draw_methods
|
||||
# names an attribute the class doesn't expose. Without this, a typo
|
||||
# only surfaces on the first redraw — as a silent missing-attribute
|
||||
# handler — which may be far from the offending declaration.
|
||||
method_names = (
|
||||
tuple(name for name, _phase in cls.draw_methods) if cls.draw_methods is not None else (cls.draw_method,)
|
||||
)
|
||||
for name in method_names:
|
||||
if getattr(cls, name, None) is None:
|
||||
raise TypeError(f"{cls.__name__}: draw method {name!r} is declared but not defined on the class")
|
||||
|
||||
@classmethod
|
||||
def install(cls, context: bpy.types.Context) -> None:
|
||||
if cls.is_installed:
|
||||
cls.uninstall()
|
||||
handler = cls()
|
||||
bindings = cls.draw_methods if cls.draw_methods is not None else ((cls.draw_method, "POST_VIEW"),)
|
||||
# Rollback partial registrations on any draw_handler_add failure, so
|
||||
# cls.handlers never ends up holding a half-installed set.
|
||||
added: list = []
|
||||
try:
|
||||
for method_name, phase in bindings:
|
||||
added.append(
|
||||
bpy.types.SpaceView3D.draw_handler_add(
|
||||
getattr(handler, method_name), (context,), "WINDOW", phase
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
for h in added:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
raise
|
||||
cls.handlers = added
|
||||
cls.is_installed = True
|
||||
|
||||
@classmethod
|
||||
def uninstall(cls) -> None:
|
||||
for h in cls.handlers:
|
||||
try:
|
||||
bpy.types.SpaceView3D.draw_handler_remove(h, "WINDOW")
|
||||
except ValueError:
|
||||
pass
|
||||
cls.handlers.clear()
|
||||
cls.is_installed = False
|
||||
|
||||
@staticmethod
|
||||
def _lookup_active_instance(gizmo_cls: type, context: bpy.types.Context) -> Optional[Any]:
|
||||
"""Return the live ``GizmoGroup`` instance registered under
|
||||
``context.region``, or ``None`` if there isn't one. The per-region
|
||||
weakref dict on the gizmo class is populated by ``setup()``; multi-
|
||||
viewport setups put one entry per region in it so each region's
|
||||
decorator sees only its own region's hover state."""
|
||||
instances = getattr(gizmo_cls, "_active_instances", None)
|
||||
if not instances:
|
||||
return None
|
||||
region = getattr(context, "region", None)
|
||||
if region is None:
|
||||
return None
|
||||
ref = instances.get(region.as_pointer())
|
||||
if ref is None:
|
||||
return None
|
||||
return ref()
|
||||
|
||||
def _cursor_icon_hovered(self, gizmo_cls: type, attr_name: str, context: bpy.types.Context) -> bool:
|
||||
"""True iff the gizmo group instance in the current region exposes a gizmo
|
||||
under ``attr_name`` that reports as highlighted. Any access exception is
|
||||
swallowed so a transient bpy-state hiccup never breaks the draw loop."""
|
||||
inst = self._lookup_active_instance(gizmo_cls, context)
|
||||
if inst is None:
|
||||
return False
|
||||
try:
|
||||
return bool(getattr(inst, attr_name).is_highlight)
|
||||
except (AttributeError, ReferenceError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def sync_all(
|
||||
cls,
|
||||
context: bpy.types.Context,
|
||||
enabled: Mapping[type[ViewportDecorator], bool],
|
||||
) -> None:
|
||||
"""Drive each listed decorator to its desired install state in one call.
|
||||
|
||||
Each entry whose value is ``True`` ends up installed; each entry whose
|
||||
value is ``False`` ends up uninstalled. Pass ``True`` for always-on
|
||||
overlays so they survive subsequent file loads."""
|
||||
for decorator_cls, should_install in enabled.items():
|
||||
if should_install:
|
||||
decorator_cls.install(context)
|
||||
else:
|
||||
decorator_cls.uninstall()
|
||||
|
||||
@classmethod
|
||||
def is_view_top_down(cls, context: bpy.types.Context, threshold: float = 0.9659) -> bool:
|
||||
"""True when the viewport camera is looking ~straight down (or up) the world Z axis.
|
||||
|
||||
Default threshold of 0.9659 = cos(15°) — a 15° tilt cone around ±world Z.
|
||||
Above the threshold the world-Z axis projects to a small fraction of its
|
||||
true length on screen, so callers that lay icons or markers out along
|
||||
world Z should switch to a screen-space offset and any gizmo whose intent
|
||||
is specifically "vertical" loses its visual cue. The cone is kept narrow
|
||||
so vertical-intent gizmos stay visible across the typical orbit range of
|
||||
3D viewport work and drop out only near genuine plan view."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return False
|
||||
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
|
||||
return abs(view_forward.z) > threshold
|
||||
|
||||
@classmethod
|
||||
def top_down_factor(cls, context: bpy.types.Context, threshold: float = 0.9659) -> float:
|
||||
"""Continuous 0–1 ramp matching ``is_view_top_down``'s cone: 0 outside the
|
||||
cone, ramping linearly to 1 at strict alignment with world Z. Callers that
|
||||
want a proportional effect (an icon-stack lift growing as the view
|
||||
approaches plan) use this in place of the boolean to avoid a one-frame
|
||||
visual jump as the camera crosses the threshold."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return 0.0
|
||||
view_forward = Vector(rv3d.view_matrix.inverted().col[2][:3]).normalized()
|
||||
alignment = abs(view_forward.z)
|
||||
if alignment <= threshold:
|
||||
return 0.0
|
||||
return (alignment - threshold) / (1.0 - threshold)
|
||||
|
||||
@classmethod
|
||||
def get_screen_up_world(cls, context: bpy.types.Context) -> Vector:
|
||||
"""World-space direction corresponding to the camera's up axis (screen-vertical).
|
||||
|
||||
Returns ``+Y`` when region data is unavailable so callers can compute an
|
||||
offset without a guard branch."""
|
||||
rv3d = context.region_data
|
||||
if rv3d is None:
|
||||
return Vector((0.0, 1.0, 0.0))
|
||||
return Vector(rv3d.view_matrix.inverted().col[1][:3]).normalized()
|
||||
|
||||
@classmethod
|
||||
def get_shader_editor_context(cls) -> Union[dict[str, Any], None]:
|
||||
for screen in bpy.data.screens:
|
||||
@@ -1143,13 +1337,13 @@ class Blender(bonsai.core.tool.Blender):
|
||||
"""
|
||||
# roof and railing both finalize then drop into path-edit mode — handle
|
||||
# them before the generic finish dispatch so the path transition runs.
|
||||
if cls.is_roof(element):
|
||||
if (feature := tool.Parametric.find_by_name("roof")) and feature.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
if tool.Parametric.is_roof(element):
|
||||
if tool.Parametric.ROOF.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(tool.Parametric.ROOF.finish_op)
|
||||
bpy.ops.bim.enable_editing_roof_path()
|
||||
elif cls.is_railing(element):
|
||||
if (feature := tool.Parametric.find_by_name("railing")) and feature.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
elif tool.Parametric.is_railing(element):
|
||||
if tool.Parametric.RAILING.is_editing(obj):
|
||||
tool.Parametric.run_bim_op(tool.Parametric.RAILING.finish_op)
|
||||
bpy.ops.bim.enable_editing_railing_path()
|
||||
elif feature := tool.Parametric.is_object_editing(obj):
|
||||
tool.Parametric.run_bim_op(feature.finish_op)
|
||||
@@ -1176,59 +1370,67 @@ class Blender(bonsai.core.tool.Blender):
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_railing_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRailing", "IfcRailingType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _RAILING_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_stair_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(
|
||||
obj, ("IfcStairFlight", "IfcStairFlightType", "IfcMember", "IfcMemberType", "IfcStair", "IfcStairType")
|
||||
)
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _STAIR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_window_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcWindow", "IfcWindowType", "IfcWindowStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _WINDOW_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_door_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcDoor", "IfcDoorType", "IfcDoorStyle"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _DOOR_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_eligible_for_roof_modifier(cls, obj: bpy.types.Object) -> bool:
|
||||
return tool.Blender.is_object_an_ifc_class(obj, ("IfcRoof", "IfcRoofType"))
|
||||
return tool.Blender.is_object_an_ifc_class(obj, _ROOF_MODIFIER_IFC_CLASSES)
|
||||
|
||||
@classmethod
|
||||
def is_railing(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Railing")
|
||||
def is_array_child(cls, element: entity_instance) -> bool:
|
||||
"""True if element is a CHILD of a Bonsai parametric array.
|
||||
|
||||
@classmethod
|
||||
def is_roof(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Roof")
|
||||
Children are managed replicas regenerated from the parent's pset —
|
||||
their parametric attributes (door dimensions, wall lengths, …) are
|
||||
overwritten on the next ``regenerate_array``. Parametric gizmo
|
||||
groups skip children via this predicate in ``poll``.
|
||||
|
||||
@classmethod
|
||||
def is_window(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Window")
|
||||
|
||||
@classmethod
|
||||
def is_door(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Door")
|
||||
|
||||
@classmethod
|
||||
def is_stair(cls, element: entity_instance) -> bool:
|
||||
return tool.Pset.get_element_pset(element, "BBIM_Stair")
|
||||
|
||||
@classmethod
|
||||
def is_wall(cls, element: entity_instance) -> bool:
|
||||
"""A wall is editable by the parametric gizmo if it is an IfcWall with LAYER2 usage.
|
||||
|
||||
Unlike doors/windows/stairs, walls do not carry a proprietary BBIM_Wall pset —
|
||||
their parametric state lives in standard IFC (axis polyline, IfcMaterialLayerSetUsage,
|
||||
IfcExtrudedAreaSolid). Any LAYER2 wall qualifies."""
|
||||
if not element.is_a("IfcWall"):
|
||||
This sits on a different axis from ``tool.Parametric.is_array``:
|
||||
cardinality (parent vs child) is orthogonal to feature kind, and
|
||||
an arrayed wall fires both ``is_wall`` and ``is_array`` on the
|
||||
same element."""
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Model.get_usage_type(element) == "LAYER2"
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return False
|
||||
parent_guid = pset.get("Parent")
|
||||
return parent_guid is not None and parent_guid != element.GlobalId
|
||||
|
||||
@classmethod
|
||||
def is_editing_railing_path(cls, obj: bpy.types.Object):
|
||||
def is_slab(cls, element: entity_instance) -> bool:
|
||||
"""A slab is host-eligible for the parametric add-opening gizmo if
|
||||
it is an IfcSlab with LAYER3 usage.
|
||||
|
||||
Slabs carry no proprietary BBIM_Slab pset — their parametric state
|
||||
lives in standard IFC (extrusion depth, IfcMaterialLayerSetUsage
|
||||
with LayerSetDirection AXIS3). Any LAYER3 slab qualifies."""
|
||||
if element is None or not element.is_a("IfcSlab"):
|
||||
return False
|
||||
return tool.Model.get_usage_type(element) == "LAYER3"
|
||||
|
||||
@classmethod
|
||||
def is_pipe_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcPipeSegment")
|
||||
|
||||
@classmethod
|
||||
def is_duct_segment(cls, element: entity_instance) -> bool:
|
||||
return element is not None and element.is_a("IfcDuctSegment")
|
||||
|
||||
@classmethod
|
||||
def is_editing_railing_path(cls, obj: bpy.types.Object) -> bool:
|
||||
props = tool.Model.get_railing_props(obj)
|
||||
return props.is_editing_path
|
||||
|
||||
@@ -1242,79 +1444,6 @@ class Blender(bonsai.core.tool.Blender):
|
||||
feature = tool.Parametric.find_for_element(element)
|
||||
return bool(feature and feature.has_non_editable_path)
|
||||
|
||||
class Array:
|
||||
@classmethod
|
||||
def bake_children_transform(cls, parent_element: entity_instance, item: int) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
with bpy.context.temp_override(object=child):
|
||||
bpy.ops.constraint.apply(constraint=constraint.name, owner="OBJECT")
|
||||
|
||||
@classmethod
|
||||
def constrain_children_to_parent(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
if not (parent_obj := tool.Ifc.get_object(parent_element)):
|
||||
return # Filtered out, arrayed void, etc
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
constraint = child.constraints.new("CHILD_OF")
|
||||
constraint.name = "BBIM_Array_CHILD_OF"
|
||||
assert isinstance(constraint, bpy.types.ChildOfConstraint)
|
||||
constraint.target = parent_obj
|
||||
|
||||
@classmethod
|
||||
def set_children_lock_state(
|
||||
cls, parent_element: ifcopenshell.entity_instance, item: int, lock_state: bool = True
|
||||
) -> None:
|
||||
modifier_data = list(cls.get_modifiers_data(parent_element))[item]
|
||||
children = cls.get_children_objects(modifier_data)
|
||||
for child_obj in children:
|
||||
Blender.lock_transform(child_obj, lock_state)
|
||||
|
||||
@classmethod
|
||||
def remove_constraints(cls, parent_element: ifcopenshell.entity_instance) -> None:
|
||||
children = cls.get_all_children_objects(parent_element)
|
||||
for child in children:
|
||||
constraint = next((c for c in child.constraints if c.type == "CHILD_OF"), None)
|
||||
if constraint:
|
||||
child.constraints.remove(constraint)
|
||||
|
||||
@classmethod
|
||||
def get_all_objects(cls, parent_element: ifcopenshell.entity_instance) -> list[bpy.types.Object]:
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
assert isinstance(parent_obj, bpy.types.Object)
|
||||
children_objects = list(cls.get_all_children_objects(parent_element))
|
||||
array_objects = [parent_obj] + children_objects # We ensure the parent is at index 0
|
||||
return array_objects
|
||||
|
||||
@classmethod
|
||||
def get_all_children_objects(
|
||||
cls, parent_element: ifcopenshell.entity_instance
|
||||
) -> Generator[bpy.types.Object, None, None]:
|
||||
for array_modifier in cls.get_modifiers_data(parent_element):
|
||||
yield from cls.get_children_objects(array_modifier)
|
||||
|
||||
@classmethod
|
||||
def get_modifiers_data(
|
||||
cls, parent_element: ifcopenshell.entity_instance
|
||||
) -> Generator[dict[str, Any], None, None]:
|
||||
array_pset = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array")
|
||||
yield from json.loads(array_pset["Data"])
|
||||
|
||||
@classmethod
|
||||
def get_children_objects(cls, modifier_data: dict[str, Any]) -> Generator[bpy.types.Object, None, None]:
|
||||
child_guid: str
|
||||
for child_guid in modifier_data["children"]:
|
||||
child_obj = tool.Blender.get_object_from_guid(child_guid)
|
||||
if child_obj:
|
||||
yield child_obj
|
||||
|
||||
class Attribute:
|
||||
@classmethod
|
||||
def fill_attribute(cls, data: bpy.types.ID, attribute_name: str, domain: str, data_type: str, values):
|
||||
|
||||
Reference in New Issue
Block a user