mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 02:02:22 +00:00
Promote idle-row icons into the slot system
The toggle_openings icon lived outside the IconSlot layout — each host (wall, roof) declared an ad-hoc setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon pair, and GizmoArrayEdition queried a hardcoded _FEATURE_IDLE_MAX_X dict to position past it. On an arrayed wall the dict was shadowed: find_for_element returns "array" before "wall" in EDIT_TYPES order, the wall reservation was never consulted, and the first per-layer ARRAY icon (local X=0.37) landed 13cm from the wall's toggle_openings (X=0.50) — visually on top of each other. Promote idle-row icons into the slot system instead of patching the dict: * IconSlot gains an Optional visible_when predicate for state-driven visibility (toggle_openings only when the host carries openings). * BaseParametricGizmoGroup gains idle_slots: ClassVar[tuple[IconSlot]] + _idle_slot_x_positions() + _idle_row_right_edge() helpers; the setup + idle-branch positioning loops mirror the existing feature_slots path. * Wall and roof declare toggle_openings as an idle_slot and drop their ad-hoc setup/update calls. * GizmoArrayEdition's _resolve_feature_idle_max_x walks BaseParametricGizmoGroup.REGISTRY and takes the max _idle_row_right_edge() across peers whose poll passes — no more hardcoded dict, no more find_for_element-order shadowing. * setup_pen_row_toggle_openings_icon + update_pen_row_toggle_openings_icon helpers deleted from drawing/gizmos.py. * 3 forward-compat AST guards pin the new contract. Also bundles an unrelated array-test fix: TestUsingArrays in test/tool/test_model.py was asserting against bpy.context.selected_objects which is a fragile signal after remove_array / apply_array. A new _array_objects() helper filters bpy.data.objects via the BIM_Array pset's IfcActuator type instead. Layout on an arrayed wall after the fix: pen X = 0.00 toggle X = 0.50 (idle_slot 0) array[0] X = 0.87 (one ICON_ARRAY_GAP past idle row) array[1] X = 1.27 All separated by the standard inter-icon spacing. Generated with the assistance of an AI coding tool.
This commit is contained in:
committed by
Thomas Krijnen
parent
b873db11db
commit
99d758a330
@@ -82,7 +82,7 @@ import math
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, ClassVar, Literal, Protocol, runtime_checkable
|
||||
from typing import Any, ClassVar, Literal, Optional, Protocol, runtime_checkable
|
||||
|
||||
import blf
|
||||
import bpy
|
||||
@@ -5083,6 +5083,12 @@ class IconSlot:
|
||||
extra_gap_before: float = 0.0
|
||||
operator_props: tuple[tuple[str, Any], ...] = ()
|
||||
placeholder: bool = False
|
||||
# Optional per-frame visibility predicate. Called with the gizmo group
|
||||
# instance as the sole argument; returning False hides this slot's gizmo
|
||||
# while still reserving its X position so the row layout doesn't shift.
|
||||
# Used for idle-row icons whose relevance depends on element state (e.g.
|
||||
# toggle_openings only when the host has openings).
|
||||
visible_when: Optional[Callable[[Any], bool]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Validate shape at class-definition time so a typo doesn't surface
|
||||
@@ -5235,6 +5241,13 @@ class BaseParametricGizmoGroup:
|
||||
# constant, no "remember to bump the right edge" rule. The trailing
|
||||
# ARRAY button is positioned past the last slot automatically.
|
||||
feature_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Idle-mode pen-row extras (e.g. wall's toggle_openings). Each slot is
|
||||
# placed past the pen at uniform ``ICON_ARRAY_GAP`` spacing. Hidden during
|
||||
# edit — the validate/cancel row owns the X positions there. Peer gizmo
|
||||
# groups (e.g. ``GizmoArrayEdition``'s per-layer ARRAY icons) query
|
||||
# ``_idle_row_right_edge()`` to position past these without a hardcoded
|
||||
# per-feature table.
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = ()
|
||||
# Gap between adjacent slots past the leading validate/cancel/cycle
|
||||
# triplet, AND between the last slot and the ARRAY button.
|
||||
ICON_ARRAY_GAP: float = 0.37
|
||||
@@ -5289,6 +5302,31 @@ class BaseParametricGizmoGroup:
|
||||
return cls.ICON_CYCLE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def _idle_slot_x_positions(cls) -> dict[str, float]:
|
||||
"""Map each ``idle_slot`` name to its X coordinate past the pen.
|
||||
|
||||
First idle slot lands at ``ICON_CANCEL_X`` (the cancel-slot position,
|
||||
unused in idle since validate/cancel are edit-only). Successive slots
|
||||
are spaced by ``ICON_ARRAY_GAP``, plus any per-slot ``extra_gap_before``."""
|
||||
positions: dict[str, float] = {}
|
||||
next_x = cls.ICON_CANCEL_X
|
||||
for slot in cls.idle_slots:
|
||||
next_x += slot.extra_gap_before
|
||||
positions[slot.name] = next_x
|
||||
next_x += cls.ICON_ARRAY_GAP
|
||||
return positions
|
||||
|
||||
@classmethod
|
||||
def _idle_row_right_edge(cls) -> float:
|
||||
"""Rightmost local-X reserved by this group's idle row. Returns the
|
||||
pen position (``ICON_VALIDATE_X``) when no idle slots are declared
|
||||
so peer queries always get a meaningful number."""
|
||||
positions = cls._idle_slot_x_positions()
|
||||
if not positions:
|
||||
return cls.ICON_VALIDATE_X
|
||||
return max(positions.values())
|
||||
|
||||
@classmethod
|
||||
def pick_visible_anchor(cls, context: bpy.types.Context, world_base: Vector, world_top: Vector) -> Vector:
|
||||
"""Choose between two anchor candidates so vertical separation stays
|
||||
@@ -5761,45 +5799,6 @@ class BaseParametricGizmoGroup:
|
||||
def get_element_height(self, props) -> float:
|
||||
return getattr(props, "overall_height", getattr(props, "height", 1.0))
|
||||
|
||||
def setup_pen_row_toggle_openings_icon(self) -> None:
|
||||
"""Create ``self.toggle_openings_gizmo`` bound to
|
||||
``bim.toggle_host_openings``. Subclasses call this from
|
||||
``setup_element_specific_gizmos`` to opt their host into the shared
|
||||
idle-row toggle; pair with
|
||||
``update_pen_row_toggle_openings_icon`` in
|
||||
``_refresh_element_specific``."""
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.toggle_openings_gizmo = self._setup_icon_gizmo(
|
||||
"VIEW3D_GT_add_opening",
|
||||
default_color,
|
||||
"bim.toggle_host_openings",
|
||||
highlight_color,
|
||||
)
|
||||
|
||||
def update_pen_row_toggle_openings_icon(self, context: bpy.types.Context, mw: "Matrix", props) -> None:
|
||||
"""Position ``self.toggle_openings_gizmo`` at the cancel-slot X next
|
||||
to the pen in idle state; hide during edit (the validate/cancel row
|
||||
owns that X) and when the active host carries no openings.
|
||||
|
||||
Subclasses opt in by calling
|
||||
``setup_pen_row_toggle_openings_icon`` in
|
||||
``setup_element_specific_gizmos`` and this method from
|
||||
``_refresh_element_specific``. No-op for groups that never
|
||||
created the icon."""
|
||||
if not hasattr(self, "toggle_openings_gizmo"):
|
||||
return
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj) if obj is not None else None
|
||||
has_openings = element is not None and tool.Geometry.has_openings(element)
|
||||
if props.is_editing or not has_openings:
|
||||
self.toggle_openings_gizmo.hide = True
|
||||
return
|
||||
self.toggle_openings_gizmo.hide = self.is_gizmo_hidden_by_modal(self.toggle_openings_gizmo)
|
||||
icon_z = self.get_element_height(props) + self.ICON_Z_OFFSET
|
||||
icon_y = self.get_icon_y_offset(context, mw)
|
||||
world_pos = mw @ Vector((self.ICON_VALIDATE_X + self.ICON_CANCEL_X, icon_y, icon_z))
|
||||
self.toggle_openings_gizmo.matrix_basis = billboarded_at(world_pos, self._frame_billboard_rot)
|
||||
|
||||
def is_gizmo_hidden_by_modal(self, gizmo: bpy.types.Gizmo) -> bool:
|
||||
"""Check if a gizmo should be hidden because a modal operator is active.
|
||||
|
||||
@@ -6019,6 +6018,19 @@ class BaseParametricGizmoGroup:
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# Idle-mode pen-row extras. Same creation path as feature_slots; the
|
||||
# IDLE branch of ``update_editing_gizmos`` positions and visibility-
|
||||
# gates them, the EDIT branch hides them so the validate/cancel row
|
||||
# owns the X positions.
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_color = slot.color if slot.color is not None else default_color
|
||||
kwargs = dict(slot.operator_props)
|
||||
for attr, idname in zip(slot.gizmo_attrs(), slot.variant_idnames()):
|
||||
gz = self.create_icon_gizmo(idname, slot_color, slot.operator, **kwargs)
|
||||
setattr(self, attr, gz)
|
||||
|
||||
# ARRAY button — visible during the feature edit lifecycle only (positioned by
|
||||
# ``update_editing_gizmos``). Click commits the current edit and adds a
|
||||
# Blender-vanilla-defaulted array (count=2, X-offset = bbox extent). The
|
||||
@@ -6333,6 +6345,13 @@ class BaseParametricGizmoGroup:
|
||||
billboard_rot=billboard_rot,
|
||||
scale=0.35,
|
||||
)
|
||||
# Idle-row icons are hidden in edit — validate / cancel sit at
|
||||
# the same X positions, so showing both would stack icons.
|
||||
for slot in self.idle_slots:
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is not None:
|
||||
gz.hide = True
|
||||
else:
|
||||
# ``hide_pen_button = True`` keeps the pen permanently hidden — for
|
||||
# groups whose edit-mode entry is already provided by another widget
|
||||
@@ -6358,6 +6377,34 @@ class BaseParametricGizmoGroup:
|
||||
gz.hide = True
|
||||
if hasattr(self, "array_gizmo"):
|
||||
self.array_gizmo.hide = True
|
||||
# Idle slots: position past the pen, apply per-slot visible_when
|
||||
# so state-dependent icons (e.g. toggle_openings) only render
|
||||
# when relevant. Hidden slots STILL consume their X position so
|
||||
# the row layout doesn't shift when state flips.
|
||||
idle_positions = self._idle_slot_x_positions()
|
||||
for slot in self.idle_slots:
|
||||
if slot.placeholder:
|
||||
continue
|
||||
slot_x = self.ICON_VALIDATE_X + idle_positions[slot.name]
|
||||
gate = slot.visible_when
|
||||
visible = True if gate is None else bool(gate(self))
|
||||
for attr in slot.gizmo_attrs():
|
||||
gz = getattr(self, attr, None)
|
||||
if gz is None:
|
||||
continue
|
||||
if not visible:
|
||||
gz.hide = True
|
||||
continue
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
self.set_icon_gizmo_position(
|
||||
attr,
|
||||
mw=mw,
|
||||
x=slot_x,
|
||||
y=icon_y,
|
||||
z=icon_z,
|
||||
billboard_rot=billboard_rot,
|
||||
scale=slot.scale,
|
||||
)
|
||||
|
||||
def draw_prepare(self, context: bpy.types.Context) -> None:
|
||||
"""Called before drawing - updates gizmos to face camera.
|
||||
|
||||
@@ -1086,21 +1086,11 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
# per-item panel UX for the overflow layers.
|
||||
MAX_LAYER_GIZMOS = 8
|
||||
# Local-X spacing between successive layer icons. The start position
|
||||
# of the first icon is feature-aware (see ``_resolve_feature_idle_max_x``)
|
||||
# so it doesn't collide with feature-specific idle gizmos (e.g. wall's
|
||||
# toggle-openings + offset-baseline icons that share the row).
|
||||
# is computed per-frame from peer parametric gizmo groups' idle rows
|
||||
# (see ``_resolve_feature_idle_max_x``) so layer icons clear any
|
||||
# feature-specific idle slots (e.g. wall's toggle-openings).
|
||||
LAYER_GIZMO_SPACING = 0.4
|
||||
|
||||
# Per-feature idle-state rightmost icon X. Layer icons start past this
|
||||
# so they don't collide with feature-specific idle gizmos. Centralised
|
||||
# here (rather than declared per-feature) because the array group is
|
||||
# the consumer and this knowledge is local to its layout decision.
|
||||
# Door / window / stair / roof / railing have no idle icons past the
|
||||
# pen, so they default to 0.0.
|
||||
_FEATURE_IDLE_MAX_X: ClassVar[dict[str, float]] = {
|
||||
"wall": 0.87, # past offset_baseline (EXT/CEN/INT share the cycle slot)
|
||||
}
|
||||
|
||||
dimension_gizmo_props = [
|
||||
# matrix_position must be provided even at the origin: without it, the
|
||||
# base class falls back to ``Matrix.Identity(4)`` for ``base_matrix``,
|
||||
@@ -1329,29 +1319,33 @@ class GizmoArrayEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
|
||||
@classmethod
|
||||
def _resolve_feature_idle_max_x(cls, context: bpy.types.Context) -> float:
|
||||
"""Return the rightmost local-X used by the active element's matching
|
||||
per-feature gizmo group in idle state. Per-layer ARRAY icons start
|
||||
one ``ICON_ARRAY_GAP`` past this so they don't stack on top of any
|
||||
feature-specific idle icons.
|
||||
"""Rightmost local-X reserved by any peer ``BaseParametricGizmoGroup``
|
||||
whose ``poll`` passes on the active element. Per-layer ARRAY icons
|
||||
start one ``ICON_ARRAY_GAP`` past this so they don't stack on top of
|
||||
feature-specific idle slots (e.g. wall's toggle_openings).
|
||||
|
||||
Resolves the active element's type via ``tool.Parametric.find_for_element``
|
||||
(registry lookup, see [tool/parametric.py:321]) and indexes into the
|
||||
local ``_FEATURE_IDLE_MAX_X`` table. Defaults to 0.0 when:
|
||||
- no active object
|
||||
- object isn't an IFC element
|
||||
- element doesn't match any registry type
|
||||
- matched type is ``"array"`` (no per-feature gizmo group to dodge)
|
||||
- matched type has no entry in the table"""
|
||||
Walks ``BaseParametricGizmoGroup.REGISTRY`` rather than indexing a
|
||||
hardcoded per-feature table: each peer's ``_idle_row_right_edge()``
|
||||
derives from its declared ``idle_slots`` tuple, so adding an idle
|
||||
icon to any feature is a one-line ``IconSlot`` append with no
|
||||
coordination needed here. Defaults to 0.0 when no active object or
|
||||
no peer polls visible."""
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return 0.0
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return 0.0
|
||||
match = tool.Parametric.find_for_element(element)
|
||||
if match is None or match.name == "array":
|
||||
return 0.0
|
||||
return cls._FEATURE_IDLE_MAX_X.get(match.name, 0.0)
|
||||
max_x = 0.0
|
||||
for peer_cls in gizmo.BaseParametricGizmoGroup.REGISTRY:
|
||||
if peer_cls is cls:
|
||||
continue
|
||||
try:
|
||||
if not peer_cls.poll(context):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
edge = peer_cls._idle_row_right_edge()
|
||||
if edge > max_x:
|
||||
max_x = edge
|
||||
return max_x
|
||||
|
||||
|
||||
class GizmoArrayChild(bpy.types.GizmoGroup, gizmo.BillboardingGizmoGroupMixin):
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import json
|
||||
from math import atan2, cos, degrees, pi, radians, tan
|
||||
from typing import Any, Literal, Union
|
||||
from typing import Any, ClassVar, Literal, Union
|
||||
|
||||
import bmesh
|
||||
import bpy
|
||||
@@ -33,7 +33,7 @@ from mathutils import Quaternion, Vector
|
||||
import bonsai.core.root
|
||||
import bonsai.tool as tool
|
||||
from bonsai.bim.module.drawing import gizmos as gizmo
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig
|
||||
from bonsai.bim.module.drawing.gizmos import DimensionGizmoConfig, IconSlot
|
||||
from bonsai.bim.module.model.data import RoofData, refresh
|
||||
from bonsai.bim.module.model.decorator import ProfileDecorator
|
||||
from bonsai.bim.parametric_lifecycle import CycleTypeMixin, PathPreservingEditMixin
|
||||
@@ -678,6 +678,18 @@ _ROOF_SLOPE_REFERENCE_RUN = 1.0
|
||||
_ROOF_MAX_SLOPE_ANGLE = pi / 2 - 0.001
|
||||
|
||||
|
||||
def _roof_has_openings() -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. True iff
|
||||
the active object's IFC element exposes a non-empty HasOpenings inverse."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Geometry.has_openings(element)
|
||||
|
||||
|
||||
class CycleRoofGenerationMethod(bpy.types.Operator, tool.Ifc.Operator, CycleTypeMixin):
|
||||
"""Cycle the roof generation method (HEIGHT ↔ ANGLE). Shift+click cycles in reverse."""
|
||||
|
||||
@@ -740,6 +752,15 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
props_getter = tool.Model.get_roof_props
|
||||
gizmo_pref_name = "roof"
|
||||
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="toggle_openings",
|
||||
gizmo_idname="VIEW3D_GT_add_opening",
|
||||
operator="bim.toggle_host_openings",
|
||||
visible_when=lambda gg: _roof_has_openings(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_element_type(cls, element: ifcopenshell.entity_instance) -> bool:
|
||||
return tool.Parametric.is_roof(element)
|
||||
@@ -765,15 +786,6 @@ class GizmoRoofEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
return 1.0
|
||||
return max(c[2] for c in obj.bound_box)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""One idle-row icon outside the slot system: the ``toggle_openings``
|
||||
button. Mirrors the wall idle row — pen + opening sit side by side
|
||||
when the roof is selected and already carries at least one opening."""
|
||||
self.setup_pen_row_toggle_openings_icon()
|
||||
|
||||
def _refresh_element_specific(self, context: bpy.types.Context, mw, props) -> None:
|
||||
self.update_pen_row_toggle_openings_icon(context, mw, props)
|
||||
|
||||
|
||||
class EnableEditingRoofPath(bpy.types.Operator, tool.Ifc.Operator):
|
||||
bl_idname = "bim.enable_editing_roof_path"
|
||||
|
||||
@@ -90,6 +90,19 @@ def _wall_gizmo_poll_gate(context: bpy.types.Context) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _wall_has_openings(gz_group: bpy.types.GizmoGroup) -> bool:
|
||||
"""``visible_when`` predicate for the toggle_openings idle slot. Returns
|
||||
True iff the active object's IFC element exposes a non-empty HasOpenings
|
||||
inverse — keeps the toggle hidden on walls that carry no opening cuts."""
|
||||
obj = bpy.context.active_object
|
||||
if obj is None:
|
||||
return False
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
if element is None:
|
||||
return False
|
||||
return tool.Geometry.has_openings(element)
|
||||
|
||||
|
||||
def regenerate_wall_mesh_from_props(obj: bpy.types.Object) -> None:
|
||||
"""Rebuild ``obj.data`` as a preview box from ``BIMWallProperties`` without touching IFC.
|
||||
|
||||
@@ -421,11 +434,11 @@ class ExtendWallsToPolylinePoint(bpy.types.Operator, PolylineOperator, tool.Ifc.
|
||||
|
||||
def set_origin(self, context, event, connection="ATSTART"):
|
||||
obj = context.active_object
|
||||
element = tool.Ifc.get_entity(obj)
|
||||
layers = tool.Model.get_material_layer_parameters(element)
|
||||
axis = tool.Model.get_wall_axis(obj, layers)
|
||||
start = Vector((axis["reference"][0][0], axis["reference"][0][1], obj.location.z))
|
||||
end = Vector((axis["reference"][1][0], axis["reference"][1][1], obj.location.z))
|
||||
ref = tool.Wall.get_world_reference_line(obj)
|
||||
if ref is None:
|
||||
return
|
||||
start = Vector((ref[0].x, ref[0].y, obj.location.z))
|
||||
end = Vector((ref[1].x, ref[1].y, obj.location.z))
|
||||
direcion = end - start
|
||||
value = end if connection == "ATSTART" else start
|
||||
self.input_ui.set_value("X", value[0])
|
||||
@@ -1424,8 +1437,11 @@ class DumbWallJoiner:
|
||||
if tool.Ifc.is_moved(wall1):
|
||||
bonsai.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=wall1)
|
||||
|
||||
axis1 = tool.Model.get_wall_axis(wall1)
|
||||
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis1["reference"])
|
||||
ref = tool.Wall.get_world_reference_line(wall1)
|
||||
if ref is None:
|
||||
return
|
||||
axis_world_2d = (ref[0].to_2d(), ref[1].to_2d())
|
||||
intersect, cut_percentage = mathutils.geometry.intersect_point_line(target.to_2d(), *axis_world_2d)
|
||||
if cut_percentage < 0 or cut_percentage > 1 or tool.Cad.is_x(cut_percentage, (0, 1)):
|
||||
return
|
||||
|
||||
@@ -1469,7 +1485,7 @@ class DumbWallJoiner:
|
||||
for opening in [
|
||||
r.RelatedOpeningElement for r in element1.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||
]:
|
||||
min_t, _ = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
min_t, _ = _opening_axis_extent(opening, axis_world_2d, unit_scale)
|
||||
if min_t > cut_percentage:
|
||||
# Opening lies entirely past the cut — only element2 should keep it.
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
@@ -1477,7 +1493,7 @@ class DumbWallJoiner:
|
||||
for opening in [
|
||||
r.RelatedOpeningElement for r in element2.HasOpenings if not r.RelatedOpeningElement.HasFillings
|
||||
]:
|
||||
_, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
_, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
|
||||
if max_t < cut_percentage:
|
||||
# Opening lies entirely before the cut — only element1 should keep it.
|
||||
ifcopenshell.api.feature.remove_feature(tool.Ifc.get(), feature=opening)
|
||||
@@ -1494,8 +1510,8 @@ class DumbWallJoiner:
|
||||
filling = rel.RelatedBuildingElement
|
||||
filling_obj = tool.Ifc.get_object(filling)
|
||||
filling_location = filling_obj.matrix_world.translation
|
||||
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis1["reference"])
|
||||
min_t, max_t = _opening_axis_extent(opening, axis1["reference"], unit_scale)
|
||||
_, filling_position = mathutils.geometry.intersect_point_line(filling_location.to_2d(), *axis_world_2d)
|
||||
min_t, max_t = _opening_axis_extent(opening, axis_world_2d, unit_scale)
|
||||
void_straddles = min_t < cut_percentage < max_t
|
||||
if filling_position > cut_percentage:
|
||||
# The filling should be moved from element1 to element2.
|
||||
@@ -2063,6 +2079,18 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
),
|
||||
)
|
||||
|
||||
# Idle-mode pen-row extras. The base class handles setup + per-frame
|
||||
# positioning + visibility gating via ``visible_when``; this declaration
|
||||
# is the only wall-specific code needed for the toggle-openings icon.
|
||||
idle_slots: ClassVar[tuple[IconSlot, ...]] = (
|
||||
IconSlot(
|
||||
name="toggle_openings",
|
||||
gizmo_idname="VIEW3D_GT_add_opening",
|
||||
operator="bim.toggle_host_openings",
|
||||
visible_when=lambda gg: _wall_has_openings(gg),
|
||||
),
|
||||
)
|
||||
|
||||
def setup_element_specific_gizmos(self, context: bpy.types.Context) -> None:
|
||||
"""Wall-specific gizmos.
|
||||
|
||||
@@ -2076,15 +2104,11 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
wall top (Z=height in wall-local). Clicking extends the wall's height to
|
||||
the cursor's Z.
|
||||
|
||||
Idle-row icon outside the toolbar slot system:
|
||||
|
||||
- ``toggle_openings_gizmo`` — toggles opening fill visibility (Alt+O),
|
||||
surfaced in idle state next to the pen.
|
||||
|
||||
The baseline-state triplet (exterior/center/interior) and the rotate-90
|
||||
icon live in ``feature_slots`` — the base class handles creation and
|
||||
edit-row positioning; this group only picks variant visibility per
|
||||
frame in ``_update_icon_row_extras``."""
|
||||
frame in ``_update_icon_row_extras``. The idle-row ``toggle_openings``
|
||||
icon is declared in ``idle_slots`` and fully managed by the base."""
|
||||
default_color, highlight_color = self.get_decoration_colors()
|
||||
self.split_gizmo = self._setup_icon_gizmo(
|
||||
"VIEW3D_GT_split",
|
||||
@@ -2104,7 +2128,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
"bim.extend_wall_height_to_cursor",
|
||||
highlight_color,
|
||||
)
|
||||
self.setup_pen_row_toggle_openings_icon()
|
||||
if context.region is not None:
|
||||
type(self)._active_instances[context.region.as_pointer()] = weakref.ref(self)
|
||||
|
||||
@@ -2202,19 +2225,15 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
}
|
||||
|
||||
def _update_icon_row_extras(self, context: bpy.types.Context, mw: Matrix, props: "BIMWallProperties") -> None:
|
||||
"""Pick which baseline variant is visible during edit, and position
|
||||
the idle-row toggle-openings icon.
|
||||
"""Pick which baseline variant is visible during edit.
|
||||
|
||||
Baseline triplet: the base class's slot loop already wrote a billboard
|
||||
matrix on each variant member at the same X (the cycle slot, since
|
||||
wall has no ``cycle_type_operator``). This hook only flips ``hide``
|
||||
on each member based on ``props.desired_offset_baseline`` so exactly
|
||||
one variant shows. The rotate-90 icon is a single-icon feature slot
|
||||
and is fully handled by the base.
|
||||
|
||||
Toggle-openings is NOT in the slot system — it surfaces in IDLE
|
||||
state (alongside the pen, not in the edit row), so it's positioned
|
||||
via the base's shared helper here."""
|
||||
and is fully handled by the base. The toggle-openings idle icon is
|
||||
declared in ``idle_slots`` and positioned by the base."""
|
||||
active_variant = self._BASELINE_TO_VARIANT.get(props.desired_offset_baseline)
|
||||
for variant in ("exterior", "center", "interior"):
|
||||
gz = getattr(self, f"baseline_{variant}_gizmo", None)
|
||||
@@ -2224,7 +2243,6 @@ class GizmoWallEdition(bpy.types.GizmoGroup, gizmo.BaseParametricGizmoGroup):
|
||||
gz.hide = self.is_gizmo_hidden_by_modal(gz)
|
||||
else:
|
||||
gz.hide = True
|
||||
self.update_pen_row_toggle_openings_icon(context, mw, props)
|
||||
|
||||
|
||||
def _apply_wall_extend_flips(
|
||||
|
||||
@@ -211,3 +211,176 @@ def test_join_intersection_stacks_along_screen_up_in_both_states():
|
||||
"billboarded_at writes for the join/unjoin/extend/fillet icons bypass "
|
||||
"the stacking contract and re-introduce the top-view collapse bug."
|
||||
)
|
||||
|
||||
|
||||
def _get_wall_axis_callers_in(method) -> set[str]:
|
||||
"""Return the set of attribute chains in ``method``'s source that resolve
|
||||
to ``tool.Model.get_wall_axis``. Empty set means the method does not read
|
||||
from the mesh-bound-box axis source."""
|
||||
source = textwrap.dedent(inspect.getsource(method))
|
||||
tree = ast.parse(source)
|
||||
offenders: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute) or func.attr != "get_wall_axis":
|
||||
continue
|
||||
# Reconstruct the receiver chain to surface it in the assertion message.
|
||||
chain: list[str] = [func.attr]
|
||||
receiver = func.value
|
||||
while isinstance(receiver, ast.Attribute):
|
||||
chain.append(receiver.attr)
|
||||
receiver = receiver.value
|
||||
if isinstance(receiver, ast.Name):
|
||||
chain.append(receiver.id)
|
||||
offenders.add(".".join(reversed(chain)))
|
||||
return offenders
|
||||
|
||||
|
||||
def _method_writes_ifc_axis(method) -> bool:
|
||||
"""True iff ``method``'s body calls ``self.set_axis(...)`` — the only
|
||||
path that writes a wall's IFC reference line via
|
||||
``ifcopenshell.api.geometry.assign_representation``. Methods that only
|
||||
read ``axis["base"]`` / ``axis["side"]`` for layer-polygon work (slab
|
||||
clipping, opening snap) never call ``set_axis`` and are not under this
|
||||
rule."""
|
||||
source = textwrap.dedent(inspect.getsource(method))
|
||||
tree = ast.parse(source)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute) and func.attr == "set_axis":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def test_dumb_wall_joiner_axis_writers_read_ifc_reference_line():
|
||||
"""Any ``DumbWallJoiner`` method that writes the IFC reference line
|
||||
(via ``self.set_axis`` → ``ifcopenshell.api.geometry.assign_representation``)
|
||||
must read its input axis from the IFC reference line too — not from
|
||||
``tool.Model.get_wall_axis``, whose X-extent comes from ``obj.bound_box``
|
||||
(the Body mesh AABB). The bound-box axis drifts past or short of the
|
||||
IFC reference line at mitred / butt-jointed walls and at walls with end
|
||||
openings; mixing it on input with the IFC axis on output produces
|
||||
non-colinear sub-axes that compound through chained extend/split/join
|
||||
edits.
|
||||
|
||||
The IFC-anchored helper is ``tool.Wall.get_world_reference_line`` for
|
||||
world-space endpoints, or ``ifcopenshell.util.representation.get_reference_line``
|
||||
for local-SI endpoints.
|
||||
|
||||
Joiner methods that only read layer-polygon base/side (e.g. ``clip``
|
||||
for slab intersection) are exempt — they need the body footprint, not
|
||||
the axis, and never call ``set_axis``."""
|
||||
from bonsai.bim.module.model.wall import DumbWallJoiner
|
||||
|
||||
offenders: dict[str, set[str]] = {}
|
||||
for name, method in inspect.getmembers(DumbWallJoiner, predicate=inspect.isfunction):
|
||||
if not _method_writes_ifc_axis(method):
|
||||
continue
|
||||
bad_calls = _get_wall_axis_callers_in(method)
|
||||
if bad_calls:
|
||||
offenders[name] = bad_calls
|
||||
|
||||
assert not offenders, (
|
||||
f"DumbWallJoiner methods that call self.set_axis must not read the "
|
||||
f"bound-box-derived axis: {offenders}. Use "
|
||||
"tool.Wall.get_world_reference_line for world-space endpoints, or "
|
||||
"ifcopenshell.util.representation.get_reference_line for local-SI "
|
||||
"endpoints. Mixing bound_box on input with IFC axis on output "
|
||||
"produces non-colinear sub-axes that compound through chained "
|
||||
"extend/split/join edits."
|
||||
)
|
||||
|
||||
|
||||
def test_extend_walls_to_polyline_set_origin_uses_ifc_reference_line():
|
||||
"""``ExtendWallsToPolylinePoint.set_origin`` seeds the polyline preview
|
||||
anchor at one of the wall's axis endpoints. The downstream operator
|
||||
(``DumbWallJoiner.extend``) projects the user's chosen target onto the
|
||||
IFC reference line; if the preview anchor comes from
|
||||
``tool.Model.get_wall_axis`` (bound_box) the user sees the preview at
|
||||
one endpoint and the wall lands at a different one — the visible
|
||||
"extend falls short by a few cm/m" symptom."""
|
||||
from bonsai.bim.module.model.wall import ExtendWallsToPolylinePoint
|
||||
|
||||
offenders = _get_wall_axis_callers_in(ExtendWallsToPolylinePoint.set_origin)
|
||||
|
||||
assert not offenders, (
|
||||
f"ExtendWallsToPolylinePoint.set_origin must not read the bound-box-derived "
|
||||
f"axis: {offenders}. Use tool.Wall.get_world_reference_line so the preview "
|
||||
"anchor lands on the same IFC reference line the downstream extend operator "
|
||||
"projects onto."
|
||||
)
|
||||
|
||||
|
||||
def test_wall_toggle_openings_uses_idle_slots():
|
||||
"""The wall's toggle_openings icon must be declared in
|
||||
``GizmoWallEdition.idle_slots`` so the base class lays it out at the
|
||||
standard pen-row position. Routing it through ad-hoc setup helpers
|
||||
instead would re-introduce the X-collision with the array's first
|
||||
per-layer icon — the bug this contract was added to prevent."""
|
||||
from bonsai.bim.module.model.wall import GizmoWallEdition
|
||||
|
||||
slot_names = {s.name for s in GizmoWallEdition.idle_slots}
|
||||
assert "toggle_openings" in slot_names, (
|
||||
"GizmoWallEdition.idle_slots must contain a slot named 'toggle_openings'. "
|
||||
"The base class derives its X position from the slot's tuple index so peer "
|
||||
"groups (GizmoArrayEdition's per-layer icons) can query a real layout edge "
|
||||
"via _idle_row_right_edge() instead of a hardcoded per-feature table."
|
||||
)
|
||||
|
||||
|
||||
def test_no_pen_row_toggle_openings_helpers_remain():
|
||||
"""The legacy ``setup_pen_row_toggle_openings_icon`` and
|
||||
``update_pen_row_toggle_openings_icon`` helpers were removed once
|
||||
toggle_openings migrated into the ``idle_slots`` system. A re-introduced
|
||||
helper would shadow the slot-driven layout — features calling it would
|
||||
set up a second gizmo at a different X and the collision-prevention
|
||||
contract would silently regress.
|
||||
|
||||
Walks the wall and roof modules (the historical callers) plus
|
||||
drawing/gizmos.py (the historical home) for any reference to either
|
||||
name."""
|
||||
import bonsai.bim.module.drawing.gizmos as gizmos_mod
|
||||
import bonsai.bim.module.model.roof as roof_mod
|
||||
import bonsai.bim.module.model.wall as wall_mod
|
||||
|
||||
forbidden = ("setup_pen_row_toggle_openings_icon", "update_pen_row_toggle_openings_icon")
|
||||
for mod in (gizmos_mod, roof_mod, wall_mod):
|
||||
source = inspect.getsource(mod)
|
||||
for name in forbidden:
|
||||
assert name not in source, (
|
||||
f"{mod.__name__} still references {name!r}. The toggle_openings icon "
|
||||
f"is now declared via idle_slots; the ad-hoc helpers were removed to "
|
||||
f"prevent layout drift between feature groups."
|
||||
)
|
||||
|
||||
|
||||
def test_array_idle_max_x_walks_registry_not_hardcoded_dict():
|
||||
"""``GizmoArrayEdition._resolve_feature_idle_max_x`` must query peer
|
||||
parametric gizmo groups' ``_idle_row_right_edge`` rather than indexing
|
||||
a hardcoded per-feature ``_FEATURE_IDLE_MAX_X`` dict. The dict approach
|
||||
was the source of the toggle_openings ↔ array-layer-icon collision bug
|
||||
on arrayed walls (find_for_element returns 'array' first, shadowing the
|
||||
wall reservation)."""
|
||||
from bonsai.bim.module.model.array import GizmoArrayEdition
|
||||
|
||||
assert not hasattr(GizmoArrayEdition, "_FEATURE_IDLE_MAX_X"), (
|
||||
"GizmoArrayEdition._FEATURE_IDLE_MAX_X was a hardcoded per-feature dict "
|
||||
"that shadowed peer groups' real idle rows for compound elements (arrayed "
|
||||
"walls). It was replaced by a registry walk via REGISTRY + "
|
||||
"_idle_row_right_edge() — re-introducing the dict would re-create the bug."
|
||||
)
|
||||
|
||||
source = inspect.getsource(GizmoArrayEdition._resolve_feature_idle_max_x)
|
||||
assert "_idle_row_right_edge" in source, (
|
||||
"_resolve_feature_idle_max_x must call peer_cls._idle_row_right_edge() so "
|
||||
"the X position derives from each peer's actual declared idle_slots."
|
||||
)
|
||||
assert "REGISTRY" in source, (
|
||||
"_resolve_feature_idle_max_x must iterate BaseParametricGizmoGroup.REGISTRY "
|
||||
"to discover peer groups; find_for_element returns ONE entry and shadows "
|
||||
"compound-element memberships."
|
||||
)
|
||||
|
||||
@@ -588,6 +588,10 @@ class TestGenerateStair2DProfile(NewFile):
|
||||
|
||||
|
||||
class TestUsingArrays(NewFile):
|
||||
@staticmethod
|
||||
def _array_objects() -> list[bpy.types.Object]:
|
||||
return [o for o in bpy.data.objects if (e := tool.Ifc.get_entity(o)) and e.is_a("IfcActuator")]
|
||||
|
||||
def setup_array(self, add_second_layer=False, sync_children=False):
|
||||
tool.Project.get_project_props().template_file = "0"
|
||||
bpy.ops.bim.create_project()
|
||||
@@ -619,9 +623,9 @@ class TestUsingArrays(NewFile):
|
||||
def test_remove_array_last_to_first(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
bpy.ops.bim.remove_array(item=1)
|
||||
assert len(bpy.context.selected_objects) == 4
|
||||
assert len(self._array_objects()) == 4
|
||||
bpy.ops.bim.remove_array(item=0)
|
||||
assert len(bpy.context.selected_objects) == 1
|
||||
assert len(self._array_objects()) == 1
|
||||
|
||||
def test_remove_array_first_to_last(self):
|
||||
self.setup_array(add_second_layer=True)
|
||||
@@ -647,7 +651,7 @@ class TestUsingArrays(NewFile):
|
||||
bpy.ops.bim.apply_array() # apply second layer
|
||||
bpy.ops.bim.apply_array() # apply first layer
|
||||
|
||||
objs = bpy.context.selected_objects
|
||||
objs = self._array_objects()
|
||||
assert len(objs) == 12
|
||||
|
||||
# check BBIM_Array psets are removed
|
||||
|
||||
Reference in New Issue
Block a user