mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 10:06:47 +00:00
Relocate feature decorators to their owning modules
Three feature-specific decorators previously lived in bim/module/model/decorator.py despite owning state only their home module reads: * ArrayPreviewDecorator + ArraySelectionHighlightDecorator + draw_array_layer_children_bbox -> array.py (read array edit-state props and walk BBIM_Array psets) * WallGizmoPreviewDecorator + draw_wall_partner_bbox -> wall.py (dereference wall.py-private classes and helpers via lazy imports) decorator.py keeps cross-cutting infrastructure (BoundingBoxDecorator, SlabDirectionDecorator, WallAxisDecorator, WallFilletPreviewDecorator, PolylineDecorator, ProductDecorator) and the shared bbox primitives (bbox_world_edges, draw_polyline_segments, _BBOX_EDGES, _stroke_lines_alpha, _fill_quads_alpha) that several feature files now import. handler.py and gizmos.py update their import paths; the wall-feature lazy imports inside WallGizmoPreviewDecorator methods collapse to direct references now that the decorator lives in wall.py. No behaviour change. Wall lane 37/37, array lane 15/15, wall forward-compat 6/6, parametric-registry 8/8 still pass. Generated with the assistance of an AI coding tool.
This commit is contained in:
@@ -42,17 +42,19 @@ from bonsai.bim.ifc import IfcStore, get_cache_or_detect_lock
|
||||
from bonsai.bim.module.aggregate.decorator import AggregateDecorator
|
||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||
from bonsai.bim.module.model import wall_offset_gizmos
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
from bonsai.bim.module.model.array import (
|
||||
ArrayPreviewDecorator,
|
||||
ArraySelectionHighlightDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.data import AuthoringData
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
BoundingBoxDecorator,
|
||||
SlabDirectionDecorator,
|
||||
WallAxisDecorator,
|
||||
WallFilletPreviewDecorator,
|
||||
WallGizmoPreviewDecorator,
|
||||
)
|
||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
||||
from bonsai.bim.module.model.wall import WallGizmoPreviewDecorator
|
||||
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
|
||||
@@ -3792,7 +3792,7 @@ class GizmoArrayAll(StaticTrisGizmoMixin, bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, layer_index)
|
||||
|
||||
@@ -3877,7 +3877,7 @@ class GizmoArrayLayerIndicator(bpy.types.Gizmo):
|
||||
parent_element = tool.Ifc.get_entity(obj)
|
||||
if parent_element is None:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_array_layer_children_bbox
|
||||
from bonsai.bim.module.model.array import draw_array_layer_children_bbox
|
||||
|
||||
draw_array_layer_children_bbox(context, parent_element, self._layer_index)
|
||||
|
||||
|
||||
@@ -28,12 +28,20 @@ from mathutils import Matrix, Vector
|
||||
|
||||
import bonsai.bim.module.drawing.gizmos as gizmo
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.decorator_cache import TokenCache
|
||||
from bonsai.bim.module.drawing.gizmos import (
|
||||
COLOR_GREEN,
|
||||
COLOR_RED,
|
||||
DimensionGizmoConfig,
|
||||
IconSlot,
|
||||
)
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
_BBOX_EDGES,
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
bbox_world_edges,
|
||||
draw_polyline_segments,
|
||||
)
|
||||
from bonsai.bim.parametric_lifecycle import (
|
||||
IntegerInputDialogMixin,
|
||||
ParametricEditMixinBase,
|
||||
@@ -1410,3 +1418,259 @@ class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
|
||||
gz = getattr(self, name)
|
||||
world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET))
|
||||
gz.matrix_basis = gizmo.billboarded_at(world_pos, billboard_rot, self.ICON_SCALE)
|
||||
|
||||
|
||||
_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200
|
||||
|
||||
|
||||
def draw_array_layer_children_bbox(
|
||||
context: bpy.types.Context,
|
||||
parent_element: ifcopenshell.entity_instance,
|
||||
layer_index: int,
|
||||
max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN,
|
||||
) -> None:
|
||||
"""Paint a wireframe bbox around every child of one array layer in the
|
||||
same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight
|
||||
tracks the hover cursor one-for-one — no POST_VIEW handler, no timing lag.
|
||||
|
||||
Total: silently no-ops on missing pset, unparseable JSON, out-of-range
|
||||
layer index, unresolvable child GUIDs, or empty child geometry."""
|
||||
if layer_index < 0:
|
||||
return
|
||||
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
|
||||
if not data_text:
|
||||
return
|
||||
try:
|
||||
layers = json.loads(data_text)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if layer_index >= len(layers):
|
||||
return
|
||||
child_guids = layers[layer_index].get("children", [])
|
||||
if not child_guids:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for guid in child_guids[:max_children]:
|
||||
try:
|
||||
child_element = ifc_file.by_guid(guid)
|
||||
except RuntimeError:
|
||||
continue
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
if child_obj is None:
|
||||
continue
|
||||
segments.extend(bbox_world_edges(child_obj))
|
||||
if not segments:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
color = prefs.decorator_color_special[:3]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
color,
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
)
|
||||
|
||||
|
||||
class ArrayPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Faint bbox wireframe at each future array instance during the edit lifecycle.
|
||||
Pure GPU preview gated on the array's draft props — no IFC mutation."""
|
||||
|
||||
LINE_WIDTH = 1.2
|
||||
LINE_ALPHA = 0.45
|
||||
MAX_PREVIEW_INSTANCES = 200
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
obj = context.active_object
|
||||
if obj is None or not obj.bound_box:
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not tool.Parametric.is_array(element):
|
||||
return
|
||||
props = tool.Model.get_array_props(obj)
|
||||
if not props.is_editing:
|
||||
return
|
||||
count = int(props.count)
|
||||
if count <= 1 or count > self.MAX_PREVIEW_INSTANCES:
|
||||
return
|
||||
|
||||
segments = self._compute_segments(obj, props, count)
|
||||
if not segments:
|
||||
return
|
||||
|
||||
color = prefs.decorator_color_selected[:3]
|
||||
draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
def _compute_segments(
|
||||
self,
|
||||
parent_obj: bpy.types.Object,
|
||||
props,
|
||||
count: int,
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""World-space (start, end) line segments for the bbox edges of
|
||||
every future instance (i = 1 … count-1; i = 0 is the parent itself).
|
||||
props.x/y/z are SI — the edit-lifecycle Enable hydrates them via
|
||||
si_conversion, so no unit_scale multiplier here."""
|
||||
offset = Vector((props.x, props.y, props.z))
|
||||
if props.method == "DISTRIBUTE":
|
||||
divider = (count - 1) if count > 1 else 1
|
||||
offset = offset / divider
|
||||
|
||||
parent_mw = parent_obj.matrix_world
|
||||
parent_corners = [Vector(c) for c in parent_obj.bound_box]
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for i in range(1, count):
|
||||
delta = offset * i
|
||||
child_mw = parent_mw.copy()
|
||||
if props.use_local_space:
|
||||
child_mw.translation = parent_mw @ delta
|
||||
else:
|
||||
child_mw.translation = parent_mw.translation + delta
|
||||
world_corners = [child_mw @ corner for corner in parent_corners]
|
||||
for a, b in _BBOX_EDGES:
|
||||
segments.append((tuple(world_corners[a]), tuple(world_corners[b])))
|
||||
return segments
|
||||
|
||||
|
||||
class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Bounding-box overlay surfacing the array family of the selected object.
|
||||
|
||||
Two activation modes:
|
||||
|
||||
- **Child selected** — parent drawn in the addon's *special*
|
||||
decorator color (bright accent); other siblings in the *unselected*
|
||||
color at lower alpha so the parent stands out. The selected child
|
||||
itself keeps Blender's standard selection outline.
|
||||
- **Parent selected** (idle, not editing) — every existing child drawn
|
||||
in the *unselected* color at lower alpha. The parent is already
|
||||
visually flagged by Blender's selection outline. Suppressed during
|
||||
an active array edit lifecycle so the live preview wireframes don't
|
||||
double-draw with the existing-children overlay."""
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
PARENT_ALPHA = 0.7
|
||||
SIBLING_ALPHA = 0.35
|
||||
MAX_SIBLINGS = 200
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._family_cache: TokenCache = TokenCache()
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
if not obj.select_get():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Blender.Modifier.is_array_child(element):
|
||||
self._draw_for_child(context, prefs, element, obj)
|
||||
elif tool.Parametric.is_array(element):
|
||||
props = tool.Model.get_array_props(obj)
|
||||
if not props.is_editing:
|
||||
self._draw_for_parent(context, prefs, element, obj)
|
||||
|
||||
def _draw_for_child(self, context, prefs, element, obj):
|
||||
family = self._resolve_family_for_child(obj, element)
|
||||
if family is None:
|
||||
return
|
||||
parent_obj, sibling_objs = family
|
||||
|
||||
parent_segments = bbox_world_edges(parent_obj)
|
||||
if parent_segments:
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
parent_segments,
|
||||
prefs.decorator_color_special[:3],
|
||||
self.PARENT_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
self._draw_siblings(context, prefs, sibling_objs)
|
||||
|
||||
def _draw_for_parent(self, context, prefs, element, obj):
|
||||
child_objs = self._resolve_children_for_parent(obj, element)
|
||||
self._draw_siblings(context, prefs, child_objs)
|
||||
|
||||
def _resolve_family_for_child(self, obj, element):
|
||||
return self._family_cache.get_or_compute(
|
||||
("child", obj.session_uid, element.id()),
|
||||
lambda: self._collect_family_from_child(element, obj),
|
||||
)
|
||||
|
||||
def _resolve_children_for_parent(self, obj, element):
|
||||
return (
|
||||
self._family_cache.get_or_compute(
|
||||
("parent", obj.session_uid, element.id()),
|
||||
lambda: self._collect_children(element, exclude=obj),
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
def _draw_siblings(self, context, prefs, sibling_objs):
|
||||
if not sibling_objs:
|
||||
return
|
||||
if len(sibling_objs) > self.MAX_SIBLINGS:
|
||||
sibling_objs = sibling_objs[: self.MAX_SIBLINGS]
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for sib_obj in sibling_objs:
|
||||
segments.extend(bbox_world_edges(sib_obj))
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
prefs.decorator_color_unselected[:3],
|
||||
self.SIBLING_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
|
||||
def _collect_family_from_child(self, element, obj):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return None
|
||||
parent_guid = pset.get("Parent")
|
||||
if not parent_guid:
|
||||
return None
|
||||
try:
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return None
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
if not parent_obj:
|
||||
return None
|
||||
siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj)
|
||||
return parent_obj, siblings
|
||||
|
||||
def _collect_children(self, parent_element, exclude=None, also_exclude=None):
|
||||
parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
|
||||
if not parent_data_text:
|
||||
return []
|
||||
try:
|
||||
layers = json.loads(parent_data_text)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
children: list[bpy.types.Object] = []
|
||||
seen_ids: set[int] = set()
|
||||
if exclude is not None:
|
||||
seen_ids.add(id(exclude))
|
||||
if also_exclude is not None:
|
||||
seen_ids.add(id(also_exclude))
|
||||
for layer in layers:
|
||||
for child_guid in layer.get("children", []):
|
||||
try:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
except RuntimeError:
|
||||
continue
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
if child_obj is None or id(child_obj) in seen_ids:
|
||||
continue
|
||||
seen_ids.add(id(child_obj))
|
||||
children.append(child_obj)
|
||||
return children
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from math import cos, pi, radians, sin, tan
|
||||
from typing import Any, Literal, Optional
|
||||
from typing import Any, Literal
|
||||
|
||||
import blf
|
||||
import bmesh
|
||||
@@ -42,7 +41,6 @@ from mathutils import Matrix, Quaternion, Vector
|
||||
|
||||
import bonsai.core.geometry
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.decorator_cache import TokenCache
|
||||
from bonsai.bim.module.drawing.helper import format_distance
|
||||
|
||||
|
||||
@@ -2276,640 +2274,3 @@ def draw_polyline_segments(
|
||||
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH = 1.8
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.8
|
||||
_ARRAY_LAYER_BBOX_MAX_CHILDREN = 200
|
||||
|
||||
|
||||
def draw_array_layer_children_bbox(
|
||||
context: bpy.types.Context,
|
||||
parent_element: ifcopenshell.entity_instance,
|
||||
layer_index: int,
|
||||
max_children: int = _ARRAY_LAYER_BBOX_MAX_CHILDREN,
|
||||
) -> None:
|
||||
"""Paint a wireframe bbox around every child of one array layer in the
|
||||
same 3D pass. Called inline from gizmo ``draw()`` methods so the highlight
|
||||
tracks the hover cursor one-for-one — no POST_VIEW handler, no timing lag.
|
||||
|
||||
Total: silently no-ops on missing pset, unparseable JSON, out-of-range
|
||||
layer index, unresolvable child GUIDs, or empty child geometry."""
|
||||
if layer_index < 0:
|
||||
return
|
||||
data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
|
||||
if not data_text:
|
||||
return
|
||||
try:
|
||||
layers = json.loads(data_text)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if layer_index >= len(layers):
|
||||
return
|
||||
child_guids = layers[layer_index].get("children", [])
|
||||
if not child_guids:
|
||||
return
|
||||
ifc_file = tool.Ifc.get()
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for guid in child_guids[:max_children]:
|
||||
try:
|
||||
child_element = ifc_file.by_guid(guid)
|
||||
except RuntimeError:
|
||||
continue
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
if child_obj is None:
|
||||
continue
|
||||
segments.extend(bbox_world_edges(child_obj))
|
||||
if not segments:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
color = prefs.decorator_color_special[:3]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
color,
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
)
|
||||
|
||||
|
||||
def draw_wall_partner_bbox(
|
||||
context: bpy.types.Context,
|
||||
partner_obj: bpy.types.Object,
|
||||
) -> None:
|
||||
"""Paint a wireframe bbox around ``partner_obj`` in the same 3D pass.
|
||||
Called inline from gizmo ``draw()`` methods so the highlight tracks the
|
||||
hover cursor one-for-one — no POST_VIEW handler, no timing lag.
|
||||
|
||||
Silently no-ops if the object has no bounding box (e.g. Empties)."""
|
||||
segments = bbox_world_edges(partner_obj)
|
||||
if not segments:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
color = prefs.decorator_color_special[:3]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
color,
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
)
|
||||
|
||||
|
||||
class ArrayPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Faint bbox wireframe at each future array instance during the edit lifecycle.
|
||||
Pure GPU preview gated on the array's draft props — no IFC mutation."""
|
||||
|
||||
LINE_WIDTH = 1.2
|
||||
LINE_ALPHA = 0.45
|
||||
MAX_PREVIEW_INSTANCES = 200
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
obj = context.active_object
|
||||
if obj is None or not obj.bound_box:
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element or not tool.Parametric.is_array(element):
|
||||
return
|
||||
props = tool.Model.get_array_props(obj)
|
||||
if not props.is_editing:
|
||||
return
|
||||
count = int(props.count)
|
||||
if count <= 1 or count > self.MAX_PREVIEW_INSTANCES:
|
||||
return
|
||||
|
||||
segments = self._compute_segments(obj, props, count)
|
||||
if not segments:
|
||||
return
|
||||
|
||||
color = prefs.decorator_color_selected[:3]
|
||||
draw_polyline_segments(context, segments, color, self.LINE_ALPHA, self.LINE_WIDTH)
|
||||
|
||||
def _compute_segments(
|
||||
self,
|
||||
parent_obj: bpy.types.Object,
|
||||
props,
|
||||
count: int,
|
||||
) -> list[tuple[tuple[float, float, float], tuple[float, float, float]]]:
|
||||
"""World-space (start, end) line segments for the bbox edges of
|
||||
every future instance (i = 1 … count-1; i = 0 is the parent itself).
|
||||
props.x/y/z are SI — the edit-lifecycle Enable hydrates them via
|
||||
si_conversion, so no unit_scale multiplier here."""
|
||||
offset = Vector((props.x, props.y, props.z))
|
||||
if props.method == "DISTRIBUTE":
|
||||
divider = (count - 1) if count > 1 else 1
|
||||
offset = offset / divider
|
||||
|
||||
parent_mw = parent_obj.matrix_world
|
||||
parent_corners = [Vector(c) for c in parent_obj.bound_box]
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for i in range(1, count):
|
||||
delta = offset * i
|
||||
child_mw = parent_mw.copy()
|
||||
if props.use_local_space:
|
||||
child_mw.translation = parent_mw @ delta
|
||||
else:
|
||||
child_mw.translation = parent_mw.translation + delta
|
||||
world_corners = [child_mw @ corner for corner in parent_corners]
|
||||
for a, b in _BBOX_EDGES:
|
||||
segments.append((tuple(world_corners[a]), tuple(world_corners[b])))
|
||||
return segments
|
||||
|
||||
|
||||
class ArraySelectionHighlightDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Bounding-box overlay surfacing the array family of the selected object.
|
||||
|
||||
Two activation modes:
|
||||
|
||||
- **Child selected** — parent drawn in the addon's *special*
|
||||
decorator color (bright accent); other siblings in the *unselected*
|
||||
color at lower alpha so the parent stands out. The selected child
|
||||
itself keeps Blender's standard selection outline.
|
||||
- **Parent selected** (idle, not editing) — every existing child drawn
|
||||
in the *unselected* color at lower alpha. The parent is already
|
||||
visually flagged by Blender's selection outline. Suppressed during
|
||||
an active array edit lifecycle so the live preview wireframes don't
|
||||
double-draw with the existing-children overlay."""
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
PARENT_ALPHA = 0.7
|
||||
SIBLING_ALPHA = 0.35
|
||||
MAX_SIBLINGS = 200
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._family_cache: TokenCache = TokenCache()
|
||||
|
||||
def draw(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
if not obj.select_get():
|
||||
return
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if not element:
|
||||
return
|
||||
|
||||
if tool.Blender.Modifier.is_array_child(element):
|
||||
self._draw_for_child(context, prefs, element, obj)
|
||||
elif tool.Parametric.is_array(element):
|
||||
props = tool.Model.get_array_props(obj)
|
||||
if not props.is_editing:
|
||||
self._draw_for_parent(context, prefs, element, obj)
|
||||
|
||||
def _draw_for_child(self, context, prefs, element, obj):
|
||||
family = self._resolve_family_for_child(obj, element)
|
||||
if family is None:
|
||||
return
|
||||
parent_obj, sibling_objs = family
|
||||
|
||||
parent_segments = bbox_world_edges(parent_obj)
|
||||
if parent_segments:
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
parent_segments,
|
||||
prefs.decorator_color_special[:3],
|
||||
self.PARENT_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
self._draw_siblings(context, prefs, sibling_objs)
|
||||
|
||||
def _draw_for_parent(self, context, prefs, element, obj):
|
||||
child_objs = self._resolve_children_for_parent(obj, element)
|
||||
self._draw_siblings(context, prefs, child_objs)
|
||||
|
||||
def _resolve_family_for_child(self, obj, element):
|
||||
return self._family_cache.get_or_compute(
|
||||
("child", obj.session_uid, element.id()),
|
||||
lambda: self._collect_family_from_child(element, obj),
|
||||
)
|
||||
|
||||
def _resolve_children_for_parent(self, obj, element):
|
||||
return (
|
||||
self._family_cache.get_or_compute(
|
||||
("parent", obj.session_uid, element.id()),
|
||||
lambda: self._collect_children(element, exclude=obj),
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
def _draw_siblings(self, context, prefs, sibling_objs):
|
||||
if not sibling_objs:
|
||||
return
|
||||
if len(sibling_objs) > self.MAX_SIBLINGS:
|
||||
sibling_objs = sibling_objs[: self.MAX_SIBLINGS]
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
for sib_obj in sibling_objs:
|
||||
segments.extend(bbox_world_edges(sib_obj))
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
prefs.decorator_color_unselected[:3],
|
||||
self.SIBLING_ALPHA,
|
||||
self.LINE_WIDTH,
|
||||
)
|
||||
|
||||
def _collect_family_from_child(self, element, obj):
|
||||
pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array")
|
||||
if not pset:
|
||||
return None
|
||||
parent_guid = pset.get("Parent")
|
||||
if not parent_guid:
|
||||
return None
|
||||
try:
|
||||
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||
except RuntimeError:
|
||||
return None
|
||||
parent_obj = tool.Ifc.get_object(parent_element)
|
||||
if not parent_obj:
|
||||
return None
|
||||
siblings = self._collect_children(parent_element, exclude=obj, also_exclude=parent_obj)
|
||||
return parent_obj, siblings
|
||||
|
||||
def _collect_children(self, parent_element, exclude=None, also_exclude=None):
|
||||
parent_data_text = ifcopenshell.util.element.get_pset(parent_element, "BBIM_Array", "Data")
|
||||
if not parent_data_text:
|
||||
return []
|
||||
try:
|
||||
layers = json.loads(parent_data_text)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
children: list[bpy.types.Object] = []
|
||||
seen_ids: set[int] = set()
|
||||
if exclude is not None:
|
||||
seen_ids.add(id(exclude))
|
||||
if also_exclude is not None:
|
||||
seen_ids.add(id(also_exclude))
|
||||
for layer in layers:
|
||||
for child_guid in layer.get("children", []):
|
||||
try:
|
||||
child_element = tool.Ifc.get().by_guid(child_guid)
|
||||
except RuntimeError:
|
||||
continue
|
||||
child_obj = tool.Ifc.get_object(child_element)
|
||||
if child_obj is None or id(child_obj) in seen_ids:
|
||||
continue
|
||||
seen_ids.add(id(child_obj))
|
||||
children.append(child_obj)
|
||||
return children
|
||||
|
||||
|
||||
class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Hover-gated preview lines that visualise where a click-to-act wall
|
||||
gizmo's operator would move the wall geometry. Four state machines:
|
||||
|
||||
- **Join intersection** — when exactly two non-joined, non-collinear,
|
||||
non-parallel LAYER2 walls are selected, draws one line from each wall's
|
||||
nearest axis endpoint to the projected XY intersection. Each line stays
|
||||
at its own wall's axis Z (so for walls on different storeys the lines
|
||||
stay horizontal at their own floor levels). Mirrors the visibility of
|
||||
the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``.
|
||||
- **Extend to cursor** — when a single LAYER2 wall is selected and the
|
||||
``extend`` wall-gizmo pref is enabled, draws one line from the wall's
|
||||
nearer axis endpoint to the 3D cursor's projected X on the wall axis.
|
||||
Mirrors the visibility of the ``extend_x_gizmo`` icon in
|
||||
``GizmoWallEdition``.
|
||||
- **Extend Z to cursor** — one preview line at the cursor's projected X
|
||||
from wall base to the cursor's Z, visualising the new total height.
|
||||
Hover-gated on ``extend_z_gizmo``.
|
||||
- **Split at cursor** — one world-vertical line at the cursor's projected X
|
||||
from wall base to wall top, visualising the cut plane. Hover-gated on
|
||||
``split_gizmo``.
|
||||
|
||||
Purely a visual cue — hidden by the same gizmo-preferences toggle as the
|
||||
icons themselves."""
|
||||
|
||||
draw_method = "draw_lines"
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
# Semi-transparent so the wall body and surrounding geometry stay visible
|
||||
# under the preview quads.
|
||||
QUAD_ALPHA = 0.25
|
||||
|
||||
def draw_lines(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
# Each preview path is mutually exclusive on selection count, so they
|
||||
# can short-circuit cheaply without coordinating.
|
||||
self._draw_join_preview(context, prefs)
|
||||
self._draw_cursor_extend_preview(context, prefs)
|
||||
self._draw_cursor_extend_z_preview(context, prefs)
|
||||
self._draw_cursor_split_preview(context, prefs)
|
||||
|
||||
def _stroke(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA)
|
||||
|
||||
def _fill(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
quads: list[
|
||||
tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]
|
||||
],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
|
||||
|
||||
@staticmethod
|
||||
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]:
|
||||
"""4 world-space corners of a Z=0 wall-local rectangle, CCW when
|
||||
viewed from +Z. Used for top-down floor-projection quads so the
|
||||
extend / split previews stay legible from plan view."""
|
||||
return (
|
||||
tuple(mw @ Vector((x0, y0, 0.0))),
|
||||
tuple(mw @ Vector((x1, y0, 0.0))),
|
||||
tuple(mw @ Vector((x1, y1, 0.0))),
|
||||
tuple(mw @ Vector((x0, y1, 0.0))),
|
||||
)
|
||||
|
||||
def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Render four preview lines per wall pair — two at each wall's base
|
||||
Z, two at each wall's top Z — extending each axis to the projected
|
||||
intersection. Two lines per wall (base + top) communicate the full
|
||||
plane that the join/extend operator would weld at, not just the
|
||||
floor edge.
|
||||
|
||||
Hover colour:
|
||||
- **Join or Fillet hover** → all four lines light up (both walls
|
||||
converge at the corner; fillet is a symmetric round of the same
|
||||
corner).
|
||||
- **Extend-to-Wall hover** → only the base+top of the non-active
|
||||
wall (the wall the default-direction operator would extend).
|
||||
- Otherwise → ``decorations_colour``."""
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return
|
||||
elem_a = tool.Ifc.get_entity(selected[0])
|
||||
elem_b = tool.Ifc.get_entity(selected[1])
|
||||
if elem_a is None or elem_b is None:
|
||||
return
|
||||
if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b):
|
||||
return
|
||||
# Lazy import to avoid a circular wall.py ↔ decorator.py dependency at
|
||||
# module load. The wall helpers are module-private but stable; the
|
||||
# gizmo group and this decorator are the only callers, both routing
|
||||
# through ``_classify_wall_join_state`` for state-machine consistency.
|
||||
from bonsai.bim.module.model.wall import (
|
||||
GizmoWallJoinIntersection,
|
||||
_classify_wall_join_state,
|
||||
_wall_axis_world_segment_from_geom,
|
||||
)
|
||||
from bonsai.core import model as core_model
|
||||
|
||||
geom_a = tool.Wall.read_geometry(selected[0])
|
||||
geom_b = tool.Wall.read_geometry(selected[1])
|
||||
if geom_a is None or geom_b is None:
|
||||
return
|
||||
seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a)
|
||||
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
|
||||
parallel_threshold = core_model.PARALLEL_DOT_THRESHOLD
|
||||
collinear_tolerance = core_model.COLLINEAR_LINE_TOLERANCE
|
||||
# Only the "intersect" state shows preview lines — joined / collinear /
|
||||
# parallel each have their own gizmo icons but no extension preview.
|
||||
state, intersection_tuple = _classify_wall_join_state(
|
||||
elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance
|
||||
)
|
||||
if state != "intersect":
|
||||
return
|
||||
assert intersection_tuple is not None # tightened by the "intersect" branch
|
||||
floor_lines = core_model.wall_join_preview_lines(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
intersection_tuple,
|
||||
)
|
||||
# Top lines mirror the floor lines but lifted by each wall's height
|
||||
# (world Z, since wall axes are stored at the wall's base elevation
|
||||
# and ``height`` is the world-space extrusion above that base).
|
||||
height_a = geom_a.get("height", 0.0)
|
||||
height_b = geom_b.get("height", 0.0)
|
||||
wall_a_floor, wall_b_floor = floor_lines
|
||||
|
||||
def _lift(seg: tuple, dz: float) -> tuple:
|
||||
(sx, sy, sz), (ex, ey, ez) = seg
|
||||
return ((sx, sy, sz + dz), (ex, ey, ez + dz))
|
||||
|
||||
wall_a_top = _lift(wall_a_floor, height_a)
|
||||
wall_b_top = _lift(wall_b_floor, height_b)
|
||||
# Hover semantic by operation:
|
||||
# • Join / Fillet hover → all four lines (symmetric corner-meet).
|
||||
# • Extend-to-Wall hover → only the wall the default-direction
|
||||
# operator would actually move (the non-active wall) — both its
|
||||
# base and top lines highlight.
|
||||
join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context)
|
||||
default = tuple(prefs.decorations_colour[:3])
|
||||
selected_rgb = tuple(prefs.decorator_color_selected[:3])
|
||||
all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top]
|
||||
|
||||
if join_hovered or fillet_hovered:
|
||||
self._stroke(context, all_lines, selected_rgb)
|
||||
return
|
||||
|
||||
if extend_hovered:
|
||||
extended_idx = self._extended_wall_index(context, selected)
|
||||
if extended_idx is not None:
|
||||
extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top]
|
||||
untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top]
|
||||
self._stroke(context, untouched_lines, default)
|
||||
self._stroke(context, extended_lines, selected_rgb)
|
||||
return
|
||||
|
||||
self._stroke(context, all_lines, default)
|
||||
|
||||
@staticmethod
|
||||
def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]:
|
||||
"""Index of the non-active wall in ``selected``, or ``None``."""
|
||||
active = context.active_object
|
||||
if active is selected[0]:
|
||||
return 1
|
||||
if active is selected[1]:
|
||||
return 0
|
||||
return None
|
||||
|
||||
def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]:
|
||||
"""Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)``
|
||||
from the ``GizmoWallJoinIntersection`` instance in **the same region**
|
||||
the decorator is currently drawing in. Returns ``(False, False,
|
||||
False)`` when that region has no live gizmo group (poll → False,
|
||||
weakref cleared, or no setup yet). Read-only; 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, False, False
|
||||
try:
|
||||
return (
|
||||
bool(inst.join_icon.is_highlight),
|
||||
bool(inst.extend_to_wall_icon.is_highlight),
|
||||
bool(inst.fillet_icon.is_highlight),
|
||||
)
|
||||
except (AttributeError, ReferenceError):
|
||||
return False, False, False
|
||||
|
||||
def _active_layer2_wall_for_gizmo_preview(
|
||||
self, context: bpy.types.Context, prefs: Any
|
||||
) -> Optional[bpy.types.Object]:
|
||||
"""Active object iff it is the sole selected object, is a LAYER2 IfcWall,
|
||||
and the wall feature's gizmo prefs are enabled. Otherwise ``None``.
|
||||
Shared guard for every cursor-anchored extend-preview path so each one
|
||||
short-circuits on the same conditions the gizmo group itself uses."""
|
||||
gizmo_prefs = getattr(prefs.gizmos, "wall", None)
|
||||
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
return None
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if active not in selected or len(selected) != 1:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not tool.Parametric.is_wall(element):
|
||||
return None
|
||||
if tool.Model.get_usage_type(element) != "LAYER2":
|
||||
return None
|
||||
return active
|
||||
|
||||
def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated floor-plane preview for the extend-X icon. Quads sit
|
||||
on the Z=0 plane spanning the wall's ``offset`` to
|
||||
``offset + thickness`` Y band so the operator's effect reads from
|
||||
plan view without side-view clutter:
|
||||
|
||||
- **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one
|
||||
green ``decorator_color_selected`` quad over the extension
|
||||
(nearer endpoint → cursor X).
|
||||
- **Cursor inside the wall extent (shrink)**: green quad for the
|
||||
portion that REMAINS (cursor X → farther endpoint) + red
|
||||
``decorator_color_error`` quad for the portion the operator
|
||||
REMOVES (nearer endpoint → cursor X)."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
offset = geom.get("offset", 0.0)
|
||||
thickness = geom.get("thickness", 0.0)
|
||||
if length <= 0 or thickness <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
y_floor_0 = offset
|
||||
y_floor_1 = offset + thickness
|
||||
start_x = anchor_x
|
||||
end_x = anchor_x + length
|
||||
keep_color = tuple(prefs.decorator_color_selected[:3])
|
||||
nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x
|
||||
|
||||
def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None:
|
||||
if abs(x1 - x0) < 1e-6:
|
||||
return
|
||||
lo, hi = (x0, x1) if x0 < x1 else (x1, x0)
|
||||
self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color)
|
||||
|
||||
if start_x < cursor_local.x < end_x:
|
||||
remove_color = tuple(prefs.decorator_color_error[:3])
|
||||
farthest_x = end_x if nearest_x == start_x else start_x
|
||||
emit(nearest_x, cursor_local.x, remove_color)
|
||||
emit(cursor_local.x, farthest_x, keep_color)
|
||||
return
|
||||
emit(nearest_x, cursor_local.x, keep_color)
|
||||
|
||||
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Render one red line at the cursor's projected X, from wall base to wall top
|
||||
along the wall's local Z — the cut plane the split operator would commit.
|
||||
Hover-gated on the split icon; coloured with the destructive-action warning
|
||||
red to match the icon's own hover signal."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
height = geom.get("height", 0.0)
|
||||
if length <= 0 or height <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
if not (anchor_x < cursor_local.x < anchor_x + length):
|
||||
return
|
||||
bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
|
||||
top_world = mw @ Vector((cursor_local.x, 0.0, height))
|
||||
self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3]))
|
||||
|
||||
def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated vertical-line preview for the extend-Z icon at the
|
||||
cursor's projected X on the wall axis (y=0 reference-line plane).
|
||||
|
||||
Two cases by cursor Z relative to the wall's current height:
|
||||
|
||||
- **Cursor Z above the wall top (grow)**: one green
|
||||
``decorator_color_selected`` segment from z=height to z=cursor.z
|
||||
(the new vertical material).
|
||||
- **Cursor Z inside ``(0, height)`` (shrink)**: two segments —
|
||||
green from z=0 to z=cursor.z (the portion that REMAINS), red
|
||||
``decorator_color_error`` from z=cursor.z to z=height (the
|
||||
portion the operator REMOVES)."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
length = geom.get("length", 0.0)
|
||||
height = geom.get("height", 0.0)
|
||||
if length <= 0 or height <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
# New height must be > 0 for the operator to commit.
|
||||
if cursor_local.z <= 0:
|
||||
return
|
||||
if abs(cursor_local.z - height) < 1e-6:
|
||||
return
|
||||
keep_color = tuple(prefs.decorator_color_selected[:3])
|
||||
cursor_x = cursor_local.x
|
||||
|
||||
def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None:
|
||||
a = mw @ Vector((cursor_x, 0.0, z0))
|
||||
b = mw @ Vector((cursor_x, 0.0, z1))
|
||||
self._stroke(context, [(tuple(a), tuple(b))], color)
|
||||
|
||||
if cursor_local.z > height:
|
||||
stroke(height, cursor_local.z, keep_color)
|
||||
return
|
||||
remove_color = tuple(prefs.decorator_color_error[:3])
|
||||
stroke(0.0, cursor_local.z, keep_color)
|
||||
stroke(cursor_local.z, height, remove_color)
|
||||
|
||||
@@ -56,7 +56,16 @@ from bonsai.bim.ifc import IfcStore
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
||||
from bonsai.bim.module.model import preview_base
|
||||
from bonsai.bim.module.model.decorator import PolylineDecorator, ProductDecorator
|
||||
from bonsai.bim.module.model.decorator import (
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
PolylineDecorator,
|
||||
ProductDecorator,
|
||||
_fill_quads_alpha,
|
||||
_stroke_lines_alpha,
|
||||
bbox_world_edges,
|
||||
draw_polyline_segments,
|
||||
)
|
||||
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -3558,8 +3567,6 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
|
||||
partner = self.partner_obj
|
||||
if partner is None:
|
||||
return
|
||||
from bonsai.bim.module.model.decorator import draw_wall_partner_bbox
|
||||
|
||||
draw_wall_partner_bbox(context, partner)
|
||||
|
||||
|
||||
@@ -4084,3 +4091,355 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
|
||||
return {"CANCELLED"}
|
||||
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def draw_wall_partner_bbox(
|
||||
context: bpy.types.Context,
|
||||
partner_obj: bpy.types.Object,
|
||||
) -> None:
|
||||
"""Paint a wireframe bbox around ``partner_obj`` in the same 3D pass.
|
||||
Called inline from gizmo ``draw()`` methods so the highlight tracks the
|
||||
hover cursor one-for-one — no POST_VIEW handler, no timing lag.
|
||||
|
||||
Silently no-ops if the object has no bounding box (e.g. Empties)."""
|
||||
segments = bbox_world_edges(partner_obj)
|
||||
if not segments:
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
color = prefs.decorator_color_special[:3]
|
||||
draw_polyline_segments(
|
||||
context,
|
||||
segments,
|
||||
color,
|
||||
_BBOX_HIGHLIGHT_LINE_ALPHA,
|
||||
_BBOX_HIGHLIGHT_LINE_WIDTH,
|
||||
)
|
||||
|
||||
|
||||
class WallGizmoPreviewDecorator(tool.Blender.ViewportDecorator):
|
||||
"""Hover-gated preview lines that visualise where a click-to-act wall
|
||||
gizmo's operator would move the wall geometry. Four state machines:
|
||||
|
||||
- **Join intersection** — when exactly two non-joined, non-collinear,
|
||||
non-parallel LAYER2 walls are selected, draws one line from each wall's
|
||||
nearest axis endpoint to the projected XY intersection. Each line stays
|
||||
at its own wall's axis Z (so for walls on different storeys the lines
|
||||
stay horizontal at their own floor levels). Mirrors the visibility of
|
||||
the Join + Extend-to-Wall icons in ``GizmoWallJoinIntersection``.
|
||||
- **Extend to cursor** — when a single LAYER2 wall is selected and the
|
||||
``extend`` wall-gizmo pref is enabled, draws one line from the wall's
|
||||
nearer axis endpoint to the 3D cursor's projected X on the wall axis.
|
||||
Mirrors the visibility of the ``extend_x_gizmo`` icon in
|
||||
``GizmoWallEdition``.
|
||||
- **Extend Z to cursor** — one preview line at the cursor's projected X
|
||||
from wall base to the cursor's Z, visualising the new total height.
|
||||
Hover-gated on ``extend_z_gizmo``.
|
||||
- **Split at cursor** — one world-vertical line at the cursor's projected X
|
||||
from wall base to wall top, visualising the cut plane. Hover-gated on
|
||||
``split_gizmo``.
|
||||
|
||||
Purely a visual cue — hidden by the same gizmo-preferences toggle as the
|
||||
icons themselves."""
|
||||
|
||||
draw_method = "draw_lines"
|
||||
|
||||
LINE_WIDTH = 1.5
|
||||
LINE_ALPHA = 0.8
|
||||
QUAD_ALPHA = 0.25
|
||||
|
||||
def draw_lines(self, context: bpy.types.Context) -> None:
|
||||
if not tool.Blender.are_viewport_gizmos_enabled():
|
||||
return
|
||||
prefs = tool.Blender.get_addon_preferences()
|
||||
self._draw_join_preview(context, prefs)
|
||||
self._draw_cursor_extend_preview(context, prefs)
|
||||
self._draw_cursor_extend_z_preview(context, prefs)
|
||||
self._draw_cursor_split_preview(context, prefs)
|
||||
|
||||
def _stroke(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
segments: list[tuple[tuple[float, float, float], tuple[float, float, float]]],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_stroke_lines_alpha(context, segments, color_rgb, self.LINE_WIDTH, self.LINE_ALPHA)
|
||||
|
||||
def _fill(
|
||||
self,
|
||||
context: bpy.types.Context,
|
||||
quads: list[
|
||||
tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]
|
||||
],
|
||||
color_rgb: tuple[float, float, float],
|
||||
) -> None:
|
||||
_fill_quads_alpha(context, quads, color_rgb, self.QUAD_ALPHA)
|
||||
|
||||
@staticmethod
|
||||
def _wall_floor_quad(mw: Matrix, x0: float, x1: float, y0: float, y1: float) -> tuple[
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
tuple[float, float, float],
|
||||
]:
|
||||
"""4 world-space corners of a Z=0 wall-local rectangle, CCW when
|
||||
viewed from +Z. Used for top-down floor-projection quads so the
|
||||
extend / split previews stay legible from plan view."""
|
||||
return (
|
||||
tuple(mw @ Vector((x0, y0, 0.0))),
|
||||
tuple(mw @ Vector((x1, y0, 0.0))),
|
||||
tuple(mw @ Vector((x1, y1, 0.0))),
|
||||
tuple(mw @ Vector((x0, y1, 0.0))),
|
||||
)
|
||||
|
||||
def _draw_join_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Render four preview lines per wall pair — two at each wall's base
|
||||
Z, two at each wall's top Z — extending each axis to the projected
|
||||
intersection. Two lines per wall (base + top) communicate the full
|
||||
plane that the join/extend operator would weld at, not just the
|
||||
floor edge.
|
||||
|
||||
Hover colour:
|
||||
- **Join or Fillet hover** → all four lines light up (both walls
|
||||
converge at the corner; fillet is a symmetric round of the same
|
||||
corner).
|
||||
- **Extend-to-Wall hover** → only the base+top of the non-active
|
||||
wall (the wall the default-direction operator would extend).
|
||||
- Otherwise → ``decorations_colour``."""
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if len(selected) != 2:
|
||||
return
|
||||
elem_a = tool.Ifc.get_entity(selected[0])
|
||||
elem_b = tool.Ifc.get_entity(selected[1])
|
||||
if elem_a is None or elem_b is None:
|
||||
return
|
||||
if not tool.Parametric.is_path_connectable_wall(elem_a) or not tool.Parametric.is_path_connectable_wall(elem_b):
|
||||
return
|
||||
|
||||
geom_a = tool.Wall.read_geometry(selected[0])
|
||||
geom_b = tool.Wall.read_geometry(selected[1])
|
||||
if geom_a is None or geom_b is None:
|
||||
return
|
||||
seg_a = _wall_axis_world_segment_from_geom(selected[0], geom_a)
|
||||
seg_b = _wall_axis_world_segment_from_geom(selected[1], geom_b)
|
||||
parallel_threshold = core.PARALLEL_DOT_THRESHOLD
|
||||
collinear_tolerance = core.COLLINEAR_LINE_TOLERANCE
|
||||
state, intersection_tuple = _classify_wall_join_state(
|
||||
elem_a, elem_b, seg_a, seg_b, parallel_threshold, collinear_tolerance
|
||||
)
|
||||
if state != "intersect":
|
||||
return
|
||||
assert intersection_tuple is not None
|
||||
floor_lines = core.wall_join_preview_lines(
|
||||
(tuple(seg_a[0]), tuple(seg_a[1])),
|
||||
(tuple(seg_b[0]), tuple(seg_b[1])),
|
||||
intersection_tuple,
|
||||
)
|
||||
height_a = geom_a.get("height", 0.0)
|
||||
height_b = geom_b.get("height", 0.0)
|
||||
wall_a_floor, wall_b_floor = floor_lines
|
||||
|
||||
def _lift(seg: tuple, dz: float) -> tuple:
|
||||
(sx, sy, sz), (ex, ey, ez) = seg
|
||||
return ((sx, sy, sz + dz), (ex, ey, ez + dz))
|
||||
|
||||
wall_a_top = _lift(wall_a_floor, height_a)
|
||||
wall_b_top = _lift(wall_b_floor, height_b)
|
||||
join_hovered, extend_hovered, fillet_hovered = self._join_group_hover_state(GizmoWallJoinIntersection, context)
|
||||
default = tuple(prefs.decorations_colour[:3])
|
||||
selected_rgb = tuple(prefs.decorator_color_selected[:3])
|
||||
all_lines = [wall_a_floor, wall_a_top, wall_b_floor, wall_b_top]
|
||||
|
||||
if join_hovered or fillet_hovered:
|
||||
self._stroke(context, all_lines, selected_rgb)
|
||||
return
|
||||
|
||||
if extend_hovered:
|
||||
extended_idx = self._extended_wall_index(context, selected)
|
||||
if extended_idx is not None:
|
||||
extended_lines = [wall_a_floor, wall_a_top] if extended_idx == 0 else [wall_b_floor, wall_b_top]
|
||||
untouched_lines = [wall_b_floor, wall_b_top] if extended_idx == 0 else [wall_a_floor, wall_a_top]
|
||||
self._stroke(context, untouched_lines, default)
|
||||
self._stroke(context, extended_lines, selected_rgb)
|
||||
return
|
||||
|
||||
self._stroke(context, all_lines, default)
|
||||
|
||||
@staticmethod
|
||||
def _extended_wall_index(context: bpy.types.Context, selected: list[bpy.types.Object]) -> Optional[int]:
|
||||
"""Index of the non-active wall in ``selected``, or ``None``."""
|
||||
active = context.active_object
|
||||
if active is selected[0]:
|
||||
return 1
|
||||
if active is selected[1]:
|
||||
return 0
|
||||
return None
|
||||
|
||||
def _join_group_hover_state(self, gizmo_cls: type, context: bpy.types.Context) -> tuple[bool, bool, bool]:
|
||||
"""Return ``(join_hovered, extend_to_wall_hovered, fillet_hovered)``
|
||||
from the ``GizmoWallJoinIntersection`` instance in **the same region**
|
||||
the decorator is currently drawing in. Returns ``(False, False,
|
||||
False)`` when that region has no live gizmo group (poll → False,
|
||||
weakref cleared, or no setup yet). Read-only; 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, False, False
|
||||
try:
|
||||
return (
|
||||
bool(inst.join_icon.is_highlight),
|
||||
bool(inst.extend_to_wall_icon.is_highlight),
|
||||
bool(inst.fillet_icon.is_highlight),
|
||||
)
|
||||
except (AttributeError, ReferenceError):
|
||||
return False, False, False
|
||||
|
||||
def _active_layer2_wall_for_gizmo_preview(
|
||||
self, context: bpy.types.Context, prefs: Any
|
||||
) -> Optional[bpy.types.Object]:
|
||||
"""Active object iff it is the sole selected object, is a LAYER2 IfcWall,
|
||||
and the wall feature's gizmo prefs are enabled. Otherwise ``None``.
|
||||
Shared guard for every cursor-anchored extend-preview path so each one
|
||||
short-circuits on the same conditions the gizmo group itself uses."""
|
||||
gizmo_prefs = getattr(prefs.gizmos, "wall", None)
|
||||
if gizmo_prefs is None or not getattr(gizmo_prefs, "enabled", True):
|
||||
return None
|
||||
active = context.active_object
|
||||
if active is None:
|
||||
return None
|
||||
selected = list(tool.Blender.get_selected_objects())
|
||||
if active not in selected or len(selected) != 1:
|
||||
return None
|
||||
element = tool.Ifc.get_entity(active)
|
||||
if element is None or not tool.Parametric.is_wall(element):
|
||||
return None
|
||||
if tool.Model.get_usage_type(element) != "LAYER2":
|
||||
return None
|
||||
return active
|
||||
|
||||
def _draw_cursor_extend_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated floor-plane preview for the extend-X icon. Quads sit
|
||||
on the Z=0 plane spanning the wall's ``offset`` to
|
||||
``offset + thickness`` Y band so the operator's effect reads from
|
||||
plan view without side-view clutter:
|
||||
|
||||
- **Cursor outside ``[anchor_x, anchor_x+length]`` (grow)**: one
|
||||
green ``decorator_color_selected`` quad over the extension
|
||||
(nearer endpoint → cursor X).
|
||||
- **Cursor inside the wall extent (shrink)**: green quad for the
|
||||
portion that REMAINS (cursor X → farther endpoint) + red
|
||||
``decorator_color_error`` quad for the portion the operator
|
||||
REMOVES (nearer endpoint → cursor X)."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_x_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
offset = geom.get("offset", 0.0)
|
||||
thickness = geom.get("thickness", 0.0)
|
||||
if length <= 0 or thickness <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
y_floor_0 = offset
|
||||
y_floor_1 = offset + thickness
|
||||
start_x = anchor_x
|
||||
end_x = anchor_x + length
|
||||
keep_color = tuple(prefs.decorator_color_selected[:3])
|
||||
nearest_x = start_x if abs(cursor_local.x - start_x) < abs(cursor_local.x - end_x) else end_x
|
||||
|
||||
def emit(x0: float, x1: float, color: tuple[float, float, float]) -> None:
|
||||
if abs(x1 - x0) < 1e-6:
|
||||
return
|
||||
lo, hi = (x0, x1) if x0 < x1 else (x1, x0)
|
||||
self._fill(context, [self._wall_floor_quad(mw, lo, hi, y_floor_0, y_floor_1)], color)
|
||||
|
||||
if start_x < cursor_local.x < end_x:
|
||||
remove_color = tuple(prefs.decorator_color_error[:3])
|
||||
farthest_x = end_x if nearest_x == start_x else start_x
|
||||
emit(nearest_x, cursor_local.x, remove_color)
|
||||
emit(cursor_local.x, farthest_x, keep_color)
|
||||
return
|
||||
emit(nearest_x, cursor_local.x, keep_color)
|
||||
|
||||
def _draw_cursor_split_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Render one red line at the cursor's projected X, from wall base to wall top
|
||||
along the wall's local Z — the cut plane the split operator would commit.
|
||||
Hover-gated on the split icon; coloured with the destructive-action warning
|
||||
red to match the icon's own hover signal."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "split_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
anchor_x = geom.get("anchor_x", 0.0)
|
||||
length = geom.get("length", 0.0)
|
||||
height = geom.get("height", 0.0)
|
||||
if length <= 0 or height <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
if not (anchor_x < cursor_local.x < anchor_x + length):
|
||||
return
|
||||
bottom_world = mw @ Vector((cursor_local.x, 0.0, 0.0))
|
||||
top_world = mw @ Vector((cursor_local.x, 0.0, height))
|
||||
self._stroke(context, [(tuple(bottom_world), tuple(top_world))], tuple(prefs.decorator_color_error[:3]))
|
||||
|
||||
def _draw_cursor_extend_z_preview(self, context: bpy.types.Context, prefs: Any) -> None:
|
||||
"""Hover-gated vertical-line preview for the extend-Z icon at the
|
||||
cursor's projected X on the wall axis (y=0 reference-line plane).
|
||||
|
||||
Two cases by cursor Z relative to the wall's current height:
|
||||
|
||||
- **Cursor Z above the wall top (grow)**: one green
|
||||
``decorator_color_selected`` segment from z=height to z=cursor.z
|
||||
(the new vertical material).
|
||||
- **Cursor Z inside ``(0, height)`` (shrink)**: two segments —
|
||||
green from z=0 to z=cursor.z (the portion that REMAINS), red
|
||||
``decorator_color_error`` from z=cursor.z to z=height (the
|
||||
portion the operator REMOVES)."""
|
||||
active = self._active_layer2_wall_for_gizmo_preview(context, prefs)
|
||||
if active is None:
|
||||
return
|
||||
if not self._cursor_icon_hovered(GizmoWallEdition, "extend_z_gizmo", context):
|
||||
return
|
||||
geom = tool.Wall.read_geometry(active)
|
||||
if geom is None:
|
||||
return
|
||||
length = geom.get("length", 0.0)
|
||||
height = geom.get("height", 0.0)
|
||||
if length <= 0 or height <= 0:
|
||||
return
|
||||
mw = active.matrix_world
|
||||
cursor_local = mw.inverted() @ context.scene.cursor.location
|
||||
if cursor_local.z <= 0:
|
||||
return
|
||||
if abs(cursor_local.z - height) < 1e-6:
|
||||
return
|
||||
keep_color = tuple(prefs.decorator_color_selected[:3])
|
||||
cursor_x = cursor_local.x
|
||||
|
||||
def stroke(z0: float, z1: float, color: tuple[float, float, float]) -> None:
|
||||
a = mw @ Vector((cursor_x, 0.0, z0))
|
||||
b = mw @ Vector((cursor_x, 0.0, z1))
|
||||
self._stroke(context, [(tuple(a), tuple(b))], color)
|
||||
|
||||
if cursor_local.z > height:
|
||||
stroke(height, cursor_local.z, keep_color)
|
||||
return
|
||||
remove_color = tuple(prefs.decorator_color_error[:3])
|
||||
stroke(0.0, cursor_local.z, keep_color)
|
||||
stroke(cursor_local.z, height, remove_color)
|
||||
|
||||
Reference in New Issue
Block a user