mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-26 18:21:59 +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.aggregate.decorator import AggregateDecorator
|
||||||
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
from bonsai.bim.module.georeference.decorator import GeoreferenceDecorator
|
||||||
from bonsai.bim.module.model import wall_offset_gizmos
|
from bonsai.bim.module.model import wall_offset_gizmos
|
||||||
from bonsai.bim.module.model.data import AuthoringData
|
from bonsai.bim.module.model.array import (
|
||||||
from bonsai.bim.module.model.decorator import (
|
|
||||||
ArrayPreviewDecorator,
|
ArrayPreviewDecorator,
|
||||||
ArraySelectionHighlightDecorator,
|
ArraySelectionHighlightDecorator,
|
||||||
|
)
|
||||||
|
from bonsai.bim.module.model.data import AuthoringData
|
||||||
|
from bonsai.bim.module.model.decorator import (
|
||||||
BoundingBoxDecorator,
|
BoundingBoxDecorator,
|
||||||
SlabDirectionDecorator,
|
SlabDirectionDecorator,
|
||||||
WallAxisDecorator,
|
WallAxisDecorator,
|
||||||
WallFilletPreviewDecorator,
|
WallFilletPreviewDecorator,
|
||||||
WallGizmoPreviewDecorator,
|
|
||||||
)
|
)
|
||||||
from bonsai.bim.module.model.preview_base import discard_pending_previews
|
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
|
from bonsai.bim.module.nest.decorator import NestDecorator
|
||||||
|
|
||||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
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)
|
parent_element = tool.Ifc.get().by_guid(parent_guid)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
return
|
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)
|
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)
|
parent_element = tool.Ifc.get_entity(obj)
|
||||||
if parent_element is None:
|
if parent_element is None:
|
||||||
return
|
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)
|
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.bim.module.drawing.gizmos as gizmo
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
|
from bonsai.bim.decorator_cache import TokenCache
|
||||||
from bonsai.bim.module.drawing.gizmos import (
|
from bonsai.bim.module.drawing.gizmos import (
|
||||||
COLOR_GREEN,
|
COLOR_GREEN,
|
||||||
COLOR_RED,
|
COLOR_RED,
|
||||||
DimensionGizmoConfig,
|
DimensionGizmoConfig,
|
||||||
IconSlot,
|
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 (
|
from bonsai.bim.parametric_lifecycle import (
|
||||||
IntegerInputDialogMixin,
|
IntegerInputDialogMixin,
|
||||||
ParametricEditMixinBase,
|
ParametricEditMixinBase,
|
||||||
@@ -1410,3 +1418,259 @@ class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
|
|||||||
gz = getattr(self, name)
|
gz = getattr(self, name)
|
||||||
world_pos = mw @ Vector((x, 0, bbox_top + self.ICON_Z_OFFSET))
|
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)
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import math
|
import math
|
||||||
from math import cos, pi, radians, sin, tan
|
from math import cos, pi, radians, sin, tan
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal
|
||||||
|
|
||||||
import blf
|
import blf
|
||||||
import bmesh
|
import bmesh
|
||||||
@@ -42,7 +41,6 @@ from mathutils import Matrix, Quaternion, Vector
|
|||||||
|
|
||||||
import bonsai.core.geometry
|
import bonsai.core.geometry
|
||||||
import bonsai.tool as tool
|
import bonsai.tool as tool
|
||||||
from bonsai.bim.decorator_cache import TokenCache
|
|
||||||
from bonsai.bim.module.drawing.helper import format_distance
|
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_WIDTH = 1.8
|
||||||
_BBOX_HIGHLIGHT_LINE_ALPHA = 0.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 import gizmos as gizmo
|
||||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
||||||
from bonsai.bim.module.model import preview_base
|
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
|
from bonsai.bim.module.model.polyline import PolylineOperator
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -3558,8 +3567,6 @@ class GizmoWallLinkToggle(gizmo.GizmoLinkToggle, bpy.types.Gizmo):
|
|||||||
partner = self.partner_obj
|
partner = self.partner_obj
|
||||||
if partner is None:
|
if partner is None:
|
||||||
return
|
return
|
||||||
from bonsai.bim.module.model.decorator import draw_wall_partner_bbox
|
|
||||||
|
|
||||||
draw_wall_partner_bbox(context, partner)
|
draw_wall_partner_bbox(context, partner)
|
||||||
|
|
||||||
|
|
||||||
@@ -4084,3 +4091,355 @@ class JoinWallsIntersection(_CommitWallDraftsFirstMixin, bpy.types.Operator, too
|
|||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
_resync_walls_after_mutation(tool.Blender.get_selected_objects())
|
||||||
return {"FINISHED"}
|
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